From 2c07468908fdba9918d33d0f02c6ff872b87fefc Mon Sep 17 00:00:00 2001 From: tonihei Date: Wed, 24 May 2023 20:41:47 +0100 Subject: [PATCH] Implement Player.replaceMediaItem(s) This change moves the default logic into the actual Player implementations, but does not introduce any behavior changes compared to addMediaItems+removeMediaItems except to make the updates "atomic" in ExoPlayerImpl, SimpleBasePlayer and MediaController. It also provides backwards compatbility for cases where Players don't support the operation. Issue: google/ExoPlayer#8046 #minor-release PiperOrigin-RevId: 534945089 --- api.txt | 6 +- .../java/androidx/media3/cast/CastPlayer.java | 12 + .../androidx/media3/cast/CastPlayerTest.java | 32 + .../androidx/media3/common/BasePlayer.java | 6 + .../java/androidx/media3/common/Player.java | 11 +- .../media3/common/SimpleBasePlayer.java | 64 +- .../media3/common/SimpleBasePlayerTest.java | 553 ++++++++++++++++++ .../media3/exoplayer/ExoPlayerImpl.java | 32 + .../media3/exoplayer/SimpleExoPlayer.java | 6 + .../media3/exoplayer/ExoPlayerTest.java | 227 +++++++ .../media3/session/IMediaSession.aidl | 4 +- .../media3/session/MediaController.java | 60 +- .../session/MediaControllerImplBase.java | 73 +++ .../session/MediaControllerImplLegacy.java | 19 + .../media3/session/MediaSessionStub.java | 70 ++- .../media3/session/PlayerWrapper.java | 12 + .../common/IRemoteMediaController.aidl | 2 + .../MediaControllerStateMaskingTest.java | 328 +++++++++++ ...tateMaskingWithMediaSessionCompatTest.java | 376 ++++++++++++ .../session/MediaSessionPermissionTest.java | 15 + .../session/MediaSessionPlayerTest.java | 28 + .../media3/session/MockPlayerTest.java | 33 ++ .../MediaControllerProviderService.java | 22 + .../androidx/media3/session/MockPlayer.java | 26 +- .../media3/session/RemoteMediaController.java | 10 + .../media3/test/utils/StubPlayer.java | 5 + 26 files changed, 1974 insertions(+), 58 deletions(-) diff --git a/api.txt b/api.txt index 06cb12e068..94970710c7 100644 --- a/api.txt +++ b/api.txt @@ -751,8 +751,8 @@ package androidx.media3.common { method public void removeListener(androidx.media3.common.Player.Listener); method public void removeMediaItem(int); method public void removeMediaItems(int, int); - method public default void replaceMediaItem(int, androidx.media3.common.MediaItem); - method public default void replaceMediaItems(int, int, java.util.List); + method public void replaceMediaItem(int, androidx.media3.common.MediaItem); + method public void replaceMediaItems(int, int, java.util.List); method public void seekBack(); method public void seekForward(); method public void seekTo(long); @@ -1607,6 +1607,8 @@ package androidx.media3.session { method public final void removeListener(androidx.media3.common.Player.Listener); method public final void removeMediaItem(int); method public final void removeMediaItems(int, int); + method public final void replaceMediaItem(int, androidx.media3.common.MediaItem); + method public final void replaceMediaItems(int, int, java.util.List); method public final void seekBack(); method public final void seekForward(); method public final void seekTo(long); diff --git a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java index 1058d28282..5481c2d022 100644 --- a/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java +++ b/libraries/cast/src/main/java/androidx/media3/cast/CastPlayer.java @@ -321,6 +321,18 @@ public final class CastPlayer extends BasePlayer { moveMediaItemsInternal(uids, fromIndex, newIndex); } + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + checkArgument(fromIndex >= 0 && fromIndex <= toIndex); + int playlistSize = currentTimeline.getWindowCount(); + if (fromIndex > playlistSize) { + return; + } + toIndex = min(toIndex, playlistSize); + addMediaItems(toIndex, mediaItems); + removeMediaItems(fromIndex, toIndex); + } + @Override public void removeMediaItems(int fromIndex, int toIndex) { checkArgument(fromIndex >= 0 && toIndex >= fromIndex); diff --git a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java index 9b58996985..7856af78f0 100644 --- a/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java +++ b/libraries/cast/src/test/java/androidx/media3/cast/CastPlayerTest.java @@ -699,6 +699,38 @@ public class CastPlayerTest { .queueRemoveItems(new int[] {1, 2, 3, 4, 5}, /* customData= */ null); } + @Test + public void replaceMediaItems_callsRemoteMediaClient() { + int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2); + List mediaItems = createMediaItems(mediaQueueItemIds); + // Add two items. + addMediaItemsAndUpdateTimeline(mediaItems, mediaQueueItemIds); + String uri = "http://www.google.com/video3"; + MediaItem anotherMediaItem = + new MediaItem.Builder().setUri(uri).setMimeType(MimeTypes.APPLICATION_MPD).build(); + ImmutableList newPlaylist = ImmutableList.of(mediaItems.get(0), anotherMediaItem); + + // Replace item at position 1. + castPlayer.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of(anotherMediaItem)); + updateTimeLine( + newPlaylist, + /* mediaQueueItemIds= */ new int[] {mediaQueueItemIds[0], 123}, + /* currentItemId= */ 123); + + verify(mockRemoteMediaClient, times(2)) + .queueInsertItems(queueItemsArgumentCaptor.capture(), anyInt(), any()); + verify(mockRemoteMediaClient).queueRemoveItems(new int[] {2}, /* customData= */ null); + assertThat(queueItemsArgumentCaptor.getAllValues().get(1)[0]) + .isEqualTo(mediaItemConverter.toMediaQueueItem(anotherMediaItem)); + Timeline.Window currentWindow = + castPlayer + .getCurrentTimeline() + .getWindow(castPlayer.getCurrentMediaItemIndex(), new Timeline.Window()); + assertThat(currentWindow.uid).isEqualTo(123); + assertThat(currentWindow.mediaItem).isEqualTo(anotherMediaItem); + } + @SuppressWarnings("ConstantConditions") @Test public void addMediaItems_fillsTimeline() { diff --git a/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java index b0f31e5d21..7910d474a8 100644 --- a/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/BasePlayer.java @@ -78,6 +78,12 @@ public abstract class BasePlayer implements Player { } } + @Override + public final void replaceMediaItem(int index, MediaItem mediaItem) { + replaceMediaItems( + /* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem)); + } + @Override public final void removeMediaItem(int index) { removeMediaItems(/* fromIndex= */ index, /* toIndex= */ index + 1); diff --git a/libraries/common/src/main/java/androidx/media3/common/Player.java b/libraries/common/src/main/java/androidx/media3/common/Player.java index 2941bd5706..da0a7f763e 100644 --- a/libraries/common/src/main/java/androidx/media3/common/Player.java +++ b/libraries/common/src/main/java/androidx/media3/common/Player.java @@ -37,7 +37,6 @@ import androidx.media3.common.util.Size; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import com.google.common.base.Objects; -import com.google.common.collect.ImmutableList; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.lang.annotation.Documented; import java.lang.annotation.Retention; @@ -2158,10 +2157,7 @@ public interface Player { * of the playlist, the request is ignored. * @param mediaItem The new {@link MediaItem}. */ - default void replaceMediaItem(int index, MediaItem mediaItem) { - replaceMediaItems( - /* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem)); - } + void replaceMediaItem(int index, MediaItem mediaItem); /** * Replaces the media items at the given range of the playlist. @@ -2180,10 +2176,7 @@ public interface Player { * larger than the size of the playlist, items up to the end of the playlist are replaced. * @param mediaItems The {@linkplain MediaItem media items} to replace the range with. */ - default void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { - addMediaItems(toIndex, mediaItems); - removeMediaItems(fromIndex, toIndex); - } + void replaceMediaItems(int fromIndex, int toIndex, List mediaItems); /** * Removes the media item at the given index of the playlist. diff --git a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java index 054f55198a..92c79c68a8 100644 --- a/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java +++ b/libraries/common/src/main/java/androidx/media3/common/SimpleBasePlayer.java @@ -2141,16 +2141,43 @@ public abstract class SimpleBasePlayer extends BasePlayer { }); } - @Override - public final void replaceMediaItem(int index, MediaItem mediaItem) { - replaceMediaItems( - /* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem)); - } - @Override public final void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { - addMediaItems(toIndex, mediaItems); - removeMediaItems(fromIndex, toIndex); + verifyApplicationThreadAndInitState(); + checkArgument(fromIndex >= 0 && fromIndex <= toIndex); + State state = this.state; + int playlistSize = state.playlist.size(); + if (!shouldHandleCommand(Player.COMMAND_CHANGE_MEDIA_ITEMS) || fromIndex > playlistSize) { + return; + } + int correctedToIndex = min(toIndex, playlistSize); + updateStateForPendingOperation( + /* pendingOperation= */ handleReplaceMediaItems(fromIndex, correctedToIndex, mediaItems), + /* placeholderStateSupplier= */ () -> { + ArrayList placeholderPlaylist = new ArrayList<>(state.playlist); + for (int i = 0; i < mediaItems.size(); i++) { + placeholderPlaylist.add( + i + correctedToIndex, getPlaceholderMediaItemData(mediaItems.get(i))); + } + State updatedState; + if (!state.playlist.isEmpty()) { + updatedState = getStateWithNewPlaylist(state, placeholderPlaylist, period); + } else { + // Handle initial position update when these are the first items added to the playlist. + updatedState = + getStateWithNewPlaylistAndPosition( + state, + placeholderPlaylist, + state.currentMediaItemIndex, + state.contentPositionMsSupplier.get()); + } + if (fromIndex < correctedToIndex) { + Util.removeRange(placeholderPlaylist, fromIndex, correctedToIndex); + return getStateWithNewPlaylist(updatedState, placeholderPlaylist, period); + } else { + return updatedState; + } + }); } @Override @@ -3182,6 +3209,27 @@ public abstract class SimpleBasePlayer extends BasePlayer { throw new IllegalStateException("Missing implementation to handle COMMAND_CHANGE_MEDIA_ITEMS"); } + /** + * Handles calls to {@link Player#replaceMediaItem} and {@link Player#replaceMediaItems}. + * + *

Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. + * + * @param fromIndex The start index of the items to replace. The index is in the range 0 <= + * {@code fromIndex} < {@link #getMediaItemCount()}. + * @param toIndex The index of the first item not to be replaced (exclusive). The index is in the + * range {@code fromIndex} < {@code toIndex} <= {@link #getMediaItemCount()}. + * @param mediaItems The media items to replace the specified range with. + * @return A {@link ListenableFuture} indicating the completion of all immediate {@link State} + * changes caused by this call. + */ + @ForOverride + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + ListenableFuture addFuture = handleAddMediaItems(toIndex, mediaItems); + ListenableFuture removeFuture = handleRemoveMediaItems(fromIndex, toIndex); + return Util.transformFutureAsync(addFuture, unused -> removeFuture); + } + /** * Handles calls to {@link Player#removeMediaItem} and {@link Player#removeMediaItems}. * diff --git a/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java b/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java index bec3887ea8..975f1cdf4d 100644 --- a/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java +++ b/libraries/common/src/test/java/androidx/media3/common/SimpleBasePlayerTest.java @@ -6868,6 +6868,559 @@ public class SimpleBasePlayerTest { assertThat(callForwarded.get()).isFalse(); } + @Test + public void replaceMediaItems_immediateHandling_updatesStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + private State playerState = state; + + @Override + protected State getState() { + return playerState; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + playerState = updatedState; + return Futures.immediateVoidFuture(); + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 2, + ImmutableList.of( + new MediaItem.Builder().setMediaId("3").build(), + new MediaItem.Builder().setMediaId("4").build(), + new MediaItem.Builder().setMediaId("2").build())); + + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + } + + @Test + public void + replaceMediaItems_asyncHandlingNotReplacingCurrentItem_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(2) + .setPlaybackState(Player.STATE_READY) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 5).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(3) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 2, + ImmutableList.of( + new MediaItem.Builder().setMediaId("4").build(), + new MediaItem.Builder().setMediaId("5").build())); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(3); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(4); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("4"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.mediaItem.mediaId).isEqualTo("5"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 3, window); + assertThat(window.uid).isEqualTo(3); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(3); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItem_asyncHandlingReplacingCurrentItem_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_READY) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 4).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(2) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItem(/* index= */ 1, new MediaItem.Builder().setMediaId("4").build()); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(3); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("4"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.uid).isEqualTo(3); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + new MediaItem.Builder().setMediaId("4").build(), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(2); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItems_asyncHandlingReplacingCurrentItemWithEmptyListAndSubsequentItem_usesPlaceholderStateAndInformsListeners() { + MediaItem testMediaItem = new MediaItem.Builder().setMediaId("3").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3) + .setMediaItem(testMediaItem) + .build())) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_READY) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 3).build())) + .setCurrentMediaItemIndex(1) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.uid).isEqualTo(3); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition(testMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItems_asyncHandlingReplacingCurrentItemWithEmptyListAndNoSubsequentItem_usesPlaceholderStateAndInformsListeners() { + MediaItem testMediaItem = new MediaItem.Builder().setMediaId("1").build(); + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1) + .setMediaItem(testMediaItem) + .build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_READY) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .setCurrentMediaItemIndex(0) + .setPlaybackState(Player.STATE_ENDED) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(1); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition(testMediaItem, Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPlaybackStateChanged(Player.STATE_ENDED); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_ENDED); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItems_asyncHandlingFromPreparedEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_ENDED) + .build(); + State updatedState = + state + .buildUpon() + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .setPlaybackState(Player.STATE_BUFFERING) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return future.isDone() ? updatedState : state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems( + /* fromIndex= */ 0, + /* toIndex= */ 0, + ImmutableList.of( + new MediaItem.Builder().setMediaId("1").build(), + new MediaItem.Builder().setMediaId("2").build())); + + // Verify placeholder state and listener calls. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.mediaId).isEqualTo("1"); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("2"); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition( + new MediaItem.Builder().setMediaId("2").build(), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_BUFFERING); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline()).isEqualTo(updatedState.timeline); + verify(listener) + .onTimelineChanged(updatedState.timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + verifyNoMoreInteractions(listener); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItems_asyncHandlingFromEmptyToEmpty_usesPlaceholderStateAndInformsListeners() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist(ImmutableList.of()) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_ENDED) + .build(); + SettableFuture future = SettableFuture.create(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + return future; + } + }; + Listener listener = mock(Listener.class); + player.addListener(listener); + + player.replaceMediaItems(/* fromIndex= */ 0, /* toIndex= */ 0, ImmutableList.of()); + + // Verify placeholder state is a no-op and no listeners are called. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + + future.set(null); + + // Verify actual state update is equally a no-op. + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + verifyNoMoreInteractions(listener); + } + + @Test + public void replaceMediaItem_withoutAvailableCommand_isNotForwarded() { + State state = + new State.Builder() + .setAvailableCommands( + new Commands.Builder() + .addAllCommands() + .remove(Player.COMMAND_CHANGE_MEDIA_ITEMS) + .build()) + .setPlaylist( + ImmutableList.of(new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build())) + .build(); + AtomicBoolean callForwarded = new AtomicBoolean(); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + callForwarded.set(true); + return Futures.immediateVoidFuture(); + } + }; + + player.replaceMediaItem(/* index= */ 0, new MediaItem.Builder().setMediaId("id").build()); + + assertThat(callForwarded.get()).isFalse(); + } + + @Test + public void replaceMediaItems_withInvalidToIndex_replacesToEndOfPlaylist() { + State state = + new State.Builder() + .setAvailableCommands(new Commands.Builder().addAllCommands().build()) + .setPlaylist( + ImmutableList.of( + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 1).build(), + new SimpleBasePlayer.MediaItemData.Builder(/* uid= */ 2).build())) + .build(); + AtomicInteger fromIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + AtomicInteger toIndexInHandleMethod = new AtomicInteger(C.INDEX_UNSET); + SimpleBasePlayer player = + new SimpleBasePlayer(Looper.myLooper()) { + @Override + protected State getState() { + return state; + } + + @Override + protected ListenableFuture handleReplaceMediaItems( + int fromIndex, int toIndex, List mediaItems) { + fromIndexInHandleMethod.set(fromIndex); + toIndexInHandleMethod.set(toIndex); + return SettableFuture.create(); + } + }; + + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 5000, + ImmutableList.of(new MediaItem.Builder().setMediaId("id").build())); + + assertThat(fromIndexInHandleMethod.get()).isEqualTo(1); + assertThat(toIndexInHandleMethod.get()).isEqualTo(2); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.uid).isEqualTo(1); + assertThat(window.isPlaceholder).isFalse(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.mediaId).isEqualTo("id"); + assertThat(window.isPlaceholder).isTrue(); + } + @SuppressWarnings("deprecation") // Verifying deprecated listener calls. @Test public void seekTo_immediateHandling_updatesStateAndInformsListeners() { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index b19a1fc85a..24bfabb985 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -732,6 +732,38 @@ import java.util.concurrent.TimeoutException; /* repeatCurrentMediaItem= */ false); } + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + verifyApplicationThread(); + checkArgument(fromIndex >= 0 && toIndex >= fromIndex); + int playlistSize = mediaSourceHolderSnapshots.size(); + if (fromIndex > playlistSize) { + // Do nothing. + return; + } + toIndex = min(toIndex, playlistSize); + List mediaSources = createMediaSources(mediaItems); + if (mediaSourceHolderSnapshots.isEmpty()) { + // Handle initial items in a playlist as a set operation to ensure state changes and initial + // position are updated correctly. + setMediaSources(mediaSources, /* resetPosition= */ maskingWindowIndex == C.INDEX_UNSET); + return; + } + PlaybackInfo newPlaybackInfo = addMediaSourcesInternal(playbackInfo, toIndex, mediaSources); + newPlaybackInfo = removeMediaItemsInternal(newPlaybackInfo, fromIndex, toIndex); + boolean positionDiscontinuity = + !newPlaybackInfo.periodId.periodUid.equals(playbackInfo.periodId.periodUid); + updatePlaybackInfo( + newPlaybackInfo, + /* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + positionDiscontinuity, + DISCONTINUITY_REASON_REMOVE, + /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo), + /* ignored */ C.INDEX_UNSET, + /* repeatCurrentMediaItem= */ false); + } + @Override public void setShuffleOrder(ShuffleOrder shuffleOrder) { verifyApplicationThread(); diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java index aaeec720da..045747f9b5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/SimpleExoPlayer.java @@ -944,6 +944,12 @@ public class SimpleExoPlayer extends BasePlayer player.moveMediaItems(fromIndex, toIndex, newIndex); } + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + blockUntilConstructorFinished(); + player.replaceMediaItems(fromIndex, toIndex, mediaItems); + } + @Override public void removeMediaItems(int fromIndex, int toIndex) { blockUntilConstructorFinished(); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index b3ffa1fcee..8aef77b787 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -85,6 +85,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.robolectric.Shadows.shadowOf; import android.content.Context; @@ -12651,6 +12652,232 @@ public final class ExoPlayerTest { eventsInOrder.verify(mockListener).onPlayerError(any(), any()); } + @Test + public void replaceMediaItems_notReplacingCurrentItem_correctMasking() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.addMediaSources( + ImmutableList.of(new FakeMediaSource(), new FakeMediaSource(), new FakeMediaSource())); + player.seekToDefaultPosition(/* mediaItemIndex= */ 2); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.addListener(listener); + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 2, + ImmutableList.of( + MediaItem.fromUri("test://test.uri"), MediaItem.fromUri("test://test2.uri"))); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(3); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(4); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test.uri")); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test2.uri")); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + player.release(); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void replaceMediaItems_replacingCurrentItem_correctMasking() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.addMediaSources( + ImmutableList.of( + createFakeMediaSource("1"), createFakeMediaSource("2"), createFakeMediaSource("3"))); + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.addListener(listener); + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 2, + ImmutableList.of( + MediaItem.fromUri("test://test.uri"), MediaItem.fromUri("test://test2.uri"))); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(4); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test.uri")); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 2, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test2.uri")); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + MediaItem.fromUri("test://test.uri"), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onTracksChanged(Tracks.EMPTY); + verify(listener).onAvailableCommandsChanged(any()); + verifyNoMoreInteractions(listener); + player.release(); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void replaceMediaItems_replacingCurrentItemWithEmptyListAndSubsequentItem_correctMasking() + throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.addMediaSources( + ImmutableList.of( + createFakeMediaSource("1"), createFakeMediaSource("2"), createFakeMediaSource("3"))); + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.addListener(listener); + player.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + createFakeMediaSource("3").getMediaItem(), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onTracksChanged(Tracks.EMPTY); + verify(listener).onAvailableCommandsChanged(any()); + verifyNoMoreInteractions(listener); + player.release(); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void + replaceMediaItems_replacingCurrentItemWithEmptyListAndNoSubsequentItem_correctMasking() + throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.addMediaSources( + ImmutableList.of(createFakeMediaSource("1"), createFakeMediaSource("2"))); + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.addListener(listener); + player.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(0); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(1); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener).onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_REMOVE)); + verify(listener).onPositionDiscontinuity(Player.DISCONTINUITY_REASON_REMOVE); + verify(listener) + .onMediaItemTransition( + createFakeMediaSource("1").getMediaItem(), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onTracksChanged(Tracks.EMPTY); + verify(listener).onAvailableCommandsChanged(any()); + verify(listener).onPlaybackStateChanged(Player.STATE_ENDED); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_ENDED); + verifyNoMoreInteractions(listener); + player.release(); + } + + @SuppressWarnings("deprecation") // Testing deprecated listener call. + @Test + public void replaceMediaItems_fromPreparedEmpty_correctMasking() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.prepare(); + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + runUntilPlaybackState(player, Player.STATE_ENDED); + + player.addListener(listener); + player.replaceMediaItems( + /* fromIndex= */ 0, + /* toIndex= */ 0, + ImmutableList.of( + MediaItem.fromUri("test://test.uri"), MediaItem.fromUri("test://test2.uri"))); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 0, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test.uri")); + assertThat(window.isPlaceholder).isTrue(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test2.uri")); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verify(listener) + .onMediaItemTransition( + MediaItem.fromUri("test://test2.uri"), + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + verify(listener).onAvailableCommandsChanged(any()); + verify(listener).onPlaybackStateChanged(Player.STATE_BUFFERING); + verify(listener).onPlayerStateChanged(/* playWhenReady= */ false, Player.STATE_BUFFERING); + verifyNoMoreInteractions(listener); + player.release(); + } + + @Test + public void replaceMediaItems_fromEmptyToEmpty_doesNothing() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.prepare(); + player.seekToDefaultPosition(/* mediaItemIndex= */ 1); + runUntilPlaybackState(player, Player.STATE_ENDED); + + player.addListener(listener); + player.replaceMediaItems(/* fromIndex= */ 0, /* toIndex= */ 0, ImmutableList.of()); + + assertThat(player.getCurrentMediaItemIndex()).isEqualTo(1); + assertThat(player.getCurrentTimeline().isEmpty()).isTrue(); + verifyNoMoreInteractions(listener); + player.release(); + } + + @Test + public void replaceMediaItems_withInvalidToIndex_correctMasking() throws Exception { + ExoPlayer player = new TestExoPlayerBuilder(context).build(); + Listener listener = mock(Listener.class); + player.addMediaSources(ImmutableList.of(new FakeMediaSource(), new FakeMediaSource())); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + + player.addListener(listener); + player.replaceMediaItems( + /* fromIndex= */ 1, + /* toIndex= */ 5000, + ImmutableList.of(MediaItem.fromUri("test://test.uri"))); + + assertThat(player.getCurrentTimeline().getWindowCount()).isEqualTo(2); + Timeline.Window window = new Timeline.Window(); + player.getCurrentTimeline().getWindow(/* windowIndex= */ 1, window); + assertThat(window.mediaItem.localConfiguration.uri).isEqualTo(Uri.parse("test://test.uri")); + assertThat(window.isPlaceholder).isTrue(); + verify(listener) + .onTimelineChanged( + player.getCurrentTimeline(), Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + verifyNoMoreInteractions(listener); + player.release(); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { diff --git a/libraries/session/src/main/aidl/androidx/media3/session/IMediaSession.aidl b/libraries/session/src/main/aidl/androidx/media3/session/IMediaSession.aidl index 1d46c2e5e2..c80fc683c7 100644 --- a/libraries/session/src/main/aidl/androidx/media3/session/IMediaSession.aidl +++ b/libraries/session/src/main/aidl/androidx/media3/session/IMediaSession.aidl @@ -83,6 +83,8 @@ oneway interface IMediaSession { IMediaController caller, int seq, int currentIndex, int newIndex) = 3021; void moveMediaItems( IMediaController caller, int seq, int fromIndex, int toIndex, int newIndex) = 3022; + void replaceMediaItem(IMediaController caller, int seq, int index, in Bundle mediaItemBundle) = 3054; + void replaceMediaItems(IMediaController caller, int seq, int fromIndex, int toIndex, IBinder mediaItems) = 3055; void play(IMediaController caller, int seq) = 3023; void pause(IMediaController caller, int seq) = 3024; void prepare(IMediaController caller, int seq) = 3025; @@ -118,7 +120,7 @@ oneway interface IMediaSession { void setRatingWithMediaId( IMediaController caller, int seq, String mediaId, in Bundle rating) = 3048; void setRating(IMediaController caller, int seq, in Bundle rating) = 3049; - // Next Id for MediaSession: 3054 + // Next Id for MediaSession: 3056 void getLibraryRoot(IMediaController caller, int seq, in Bundle libraryParams) = 4000; void getItem(IMediaController caller, int seq, String mediaId) = 4001; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaController.java b/libraries/session/src/main/java/androidx/media3/session/MediaController.java index 54f6145d32..0dbaf68657 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaController.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaController.java @@ -1071,12 +1071,6 @@ public class MediaController implements Player { impl.addMediaItem(index, mediaItem); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically add items. - */ @Override public final void addMediaItems(List mediaItems) { verifyApplicationThread(); @@ -1087,12 +1081,6 @@ public class MediaController implements Player { impl.addMediaItems(mediaItems); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically add items. - */ @Override public final void addMediaItems(int index, List mediaItems) { verifyApplicationThread(); @@ -1113,12 +1101,6 @@ public class MediaController implements Player { impl.removeMediaItem(index); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically remove items. - */ @Override public final void removeMediaItems(int fromIndex, int toIndex) { verifyApplicationThread(); @@ -1129,12 +1111,6 @@ public class MediaController implements Player { impl.removeMediaItems(fromIndex, toIndex); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically clear items. - */ @Override public final void clearMediaItems() { verifyApplicationThread(); @@ -1145,12 +1121,6 @@ public class MediaController implements Player { impl.clearMediaItems(); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically move items. - */ @Override public final void moveMediaItem(int currentIndex, int newIndex) { verifyApplicationThread(); @@ -1161,12 +1131,6 @@ public class MediaController implements Player { impl.moveMediaItem(currentIndex, newIndex); } - /** - * {@inheritDoc} - * - *

Interoperability: When connected to {@link - * android.support.v4.media.session.MediaSessionCompat}, this doesn't atomically move items. - */ @Override public final void moveMediaItems(int fromIndex, int toIndex, int newIndex) { verifyApplicationThread(); @@ -1177,6 +1141,26 @@ public class MediaController implements Player { impl.moveMediaItems(fromIndex, toIndex, newIndex); } + @Override + public final void replaceMediaItem(int index, MediaItem mediaItem) { + verifyApplicationThread(); + if (!isConnected()) { + Log.w(TAG, "The controller is not connected. Ignoring replaceMediaItem()."); + return; + } + impl.replaceMediaItem(index, mediaItem); + } + + @Override + public final void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + verifyApplicationThread(); + if (!isConnected()) { + Log.w(TAG, "The controller is not connected. Ignoring replaceMediaItems()."); + return; + } + impl.replaceMediaItems(fromIndex, toIndex, mediaItems); + } + /** * @deprecated Use {@link #isCurrentMediaItemDynamic()} instead. */ @@ -2034,6 +2018,10 @@ public class MediaController implements Player { void moveMediaItems(int fromIndex, int toIndex, int newIndex); + void replaceMediaItem(int index, MediaItem mediaItem); + + void replaceMediaItems(int fromIndex, int toIndex, List mediaItems); + int getCurrentPeriodIndex(); int getCurrentMediaItemIndex(); 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 8b037dfde9..a284f999a5 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplBase.java @@ -1183,6 +1183,79 @@ import org.checkerframework.checker.nullness.qual.NonNull; moveMediaItemsInternal(fromIndex, toIndex, newIndex); } + @Override + public void replaceMediaItem(int index, MediaItem mediaItem) { + if (!isPlayerCommandAvailable(Player.COMMAND_CHANGE_MEDIA_ITEMS)) { + return; + } + checkArgument(index >= 0); + + dispatchRemoteSessionTaskWithPlayerCommand( + (iSession, seq) -> { + if (checkNotNull(connectedToken).getInterfaceVersion() >= 2) { + iSession.replaceMediaItem(controllerStub, seq, index, mediaItem.toBundle()); + } else { + iSession.addMediaItemWithIndex(controllerStub, seq, index + 1, mediaItem.toBundle()); + iSession.removeMediaItem(controllerStub, seq, index); + } + }); + replaceMediaItemsInternal( + /* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem)); + } + + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + if (!isPlayerCommandAvailable(Player.COMMAND_CHANGE_MEDIA_ITEMS)) { + return; + } + checkArgument(fromIndex >= 0 && fromIndex <= toIndex); + + dispatchRemoteSessionTaskWithPlayerCommand( + (iSession, seq) -> { + IBinder mediaItemsBundleBinder = + new BundleListRetriever(BundleableUtil.toBundleList(mediaItems)); + if (checkNotNull(connectedToken).getInterfaceVersion() >= 2) { + iSession.replaceMediaItems( + controllerStub, seq, fromIndex, toIndex, mediaItemsBundleBinder); + } else { + iSession.addMediaItemsWithIndex(controllerStub, seq, toIndex, mediaItemsBundleBinder); + iSession.removeMediaItems(controllerStub, seq, fromIndex, toIndex); + } + }); + replaceMediaItemsInternal(fromIndex, toIndex, mediaItems); + } + + private void replaceMediaItemsInternal(int fromIndex, int toIndex, List mediaItems) { + int playlistSize = playerInfo.timeline.getWindowCount(); + if (fromIndex > playlistSize) { + return; + } + if (playerInfo.timeline.isEmpty()) { + // Handle initial items in a playlist as a set operation to ensure state changes and initial + // position are updated correctly. + setMediaItemsInternal( + mediaItems, + /* startIndex= */ C.INDEX_UNSET, + /* startPositionMs= */ C.TIME_UNSET, + /* resetToDefaultPosition= */ false); + return; + } + toIndex = min(toIndex, playlistSize); + PlayerInfo newPlayerInfo = maskPlaybackInfoForAddedItems(playerInfo, toIndex, mediaItems); + newPlayerInfo = maskPlayerInfoForRemovedItems(newPlayerInfo, fromIndex, toIndex); + boolean replacedCurrentItem = + playerInfo.sessionPositionInfo.positionInfo.mediaItemIndex >= fromIndex + && playerInfo.sessionPositionInfo.positionInfo.mediaItemIndex < toIndex; + updatePlayerInfo( + newPlayerInfo, + /* timelineChangeReason= */ Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + /* ignored */ Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, + /* positionDiscontinuity= */ replacedCurrentItem, + Player.DISCONTINUITY_REASON_REMOVE, + /* mediaItemTransition= */ replacedCurrentItem, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + @Override public int getCurrentPeriodIndex() { return playerInfo.sessionPositionInfo.positionInfo.periodIndex; diff --git a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java index f16b1af489..3a28f0c478 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaControllerImplLegacy.java @@ -849,6 +849,25 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; } } + @Override + public void replaceMediaItem(int index, MediaItem mediaItem) { + replaceMediaItems( + /* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem)); + } + + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + checkArgument(fromIndex >= 0 && fromIndex <= toIndex); + QueueTimeline queueTimeline = (QueueTimeline) controllerInfo.playerInfo.timeline; + int size = queueTimeline.getWindowCount(); + if (fromIndex > size) { + return; + } + toIndex = min(toIndex, size); + addMediaItems(toIndex, mediaItems); + removeMediaItems(fromIndex, toIndex); + } + @Override public int getCurrentPeriodIndex() { return getCurrentMediaItemIndex(); 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 cfe20c0247..962f66cfc4 100644 --- a/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java +++ b/libraries/session/src/main/java/androidx/media3/session/MediaSessionStub.java @@ -113,7 +113,7 @@ import java.util.concurrent.ExecutionException; private static final String TAG = "MediaSessionStub"; /** The version of the IMediaSession interface. */ - public static final int VERSION_INT = 1; + public static final int VERSION_INT = 2; private final WeakReference sessionImpl; private final MediaSessionManager sessionManager; @@ -1270,6 +1270,74 @@ import java.util.concurrent.ExecutionException; sendSessionResultSuccess(player -> player.moveMediaItems(fromIndex, toIndex, newIndex))); } + @Override + public void replaceMediaItem( + IMediaController caller, int sequenceNumber, int index, Bundle mediaItemBundle) { + if (caller == null || mediaItemBundle == null) { + return; + } + MediaItem mediaItem; + try { + mediaItem = MediaItem.CREATOR.fromBundle(mediaItemBundle); + } catch (RuntimeException e) { + Log.w(TAG, "Ignoring malformed Bundle for MediaItem", e); + return; + } + queueSessionTaskWithPlayerCommand( + caller, + sequenceNumber, + COMMAND_CHANGE_MEDIA_ITEMS, + sendSessionResultWhenReady( + handleMediaItemsWhenReady( + (sessionImpl, controller, sequenceNum) -> + sessionImpl.onAddMediaItemsOnHandler(controller, ImmutableList.of(mediaItem)), + (player, controller, mediaItems) -> { + if (mediaItems.size() == 1) { + player.replaceMediaItem( + maybeCorrectMediaItemIndex(controller, player, index), mediaItems.get(0)); + } else { + player.replaceMediaItems( + maybeCorrectMediaItemIndex(controller, player, index), + maybeCorrectMediaItemIndex(controller, player, index + 1), + mediaItems); + } + }))); + } + + @Override + public void replaceMediaItems( + IMediaController caller, + int sequenceNumber, + int fromIndex, + int toIndex, + IBinder mediaItemsRetriever) { + if (caller == null || mediaItemsRetriever == null) { + return; + } + ImmutableList mediaItems; + try { + mediaItems = + BundleableUtil.fromBundleList( + MediaItem.CREATOR, BundleListRetriever.getList(mediaItemsRetriever)); + } catch (RuntimeException e) { + Log.w(TAG, "Ignoring malformed Bundle for MediaItem", e); + return; + } + queueSessionTaskWithPlayerCommand( + caller, + sequenceNumber, + COMMAND_CHANGE_MEDIA_ITEMS, + sendSessionResultWhenReady( + handleMediaItemsWhenReady( + (sessionImpl, controller, sequenceNum) -> + sessionImpl.onAddMediaItemsOnHandler(controller, mediaItems), + (player, controller, items) -> + player.replaceMediaItems( + maybeCorrectMediaItemIndex(controller, player, fromIndex), + maybeCorrectMediaItemIndex(controller, player, toIndex), + items)))); + } + @Override public void seekToPreviousMediaItem(@Nullable IMediaController caller, int sequenceNumber) { if (caller == null) { diff --git a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java index 25f131ce6e..1d19bd5344 100644 --- a/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java +++ b/libraries/session/src/main/java/androidx/media3/session/PlayerWrapper.java @@ -484,6 +484,18 @@ import java.util.List; super.moveMediaItems(fromIndex, toIndex, newIndex); } + @Override + public void replaceMediaItem(int index, MediaItem mediaItem) { + verifyApplicationThread(); + super.replaceMediaItem(index, mediaItem); + } + + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + verifyApplicationThread(); + super.replaceMediaItems(fromIndex, toIndex, mediaItems); + } + @Deprecated @Override public boolean hasPrevious() { diff --git a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl index fa4634852f..cad69ebcf9 100644 --- a/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl +++ b/libraries/test_session_common/src/main/aidl/androidx/media3/test/session/common/IRemoteMediaController.aidl @@ -62,6 +62,8 @@ interface IRemoteMediaController { void clearMediaItems(String controllerId); void moveMediaItem(String controllerId, int currentIndex, int newIndex); void moveMediaItems(String controllerId, int fromIndex, int toIndex, int newIndex); + void replaceMediaItem(String controllerId, int index, in Bundle mediaItem); + void replaceMediaItems(String controllerId, int fromIndex, int toIndex, in List mediaItems); void seekToPreviousMediaItem(String controllerId); void seekToNextMediaItem(String controllerId); void seekToPrevious(String controllerId); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingTest.java index 15d3dd5a65..8d0561352f 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingTest.java @@ -3240,6 +3240,334 @@ public class MediaControllerStateMaskingTest { assertThat(itemsAfterMove).containsExactly(items.get(1), items.get(0)).inOrder(); } + @Test + public void replaceMediaItems_notReplacingCurrentItem_correctMasking() throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(MediaTestUtils.createTimeline(3)) + .setCurrentMediaItemIndex(2) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, createMediaItems(2)); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(4); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(3); + assertThat(getEventsAsList(onEventsRef.get())).containsExactly(Player.EVENT_TIMELINE_CHANGED); + } + + @Test + public void replaceMediaItems_replacingCurrentItem_correctMasking() throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(MediaTestUtils.createTimeline(3)) + .setCurrentMediaItemIndex(1) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItem(/* index= */ 1, createMediaItems(1).get(0)); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(3); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(getEventsAsList(onEventsRef.get())) + .containsExactly( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_MEDIA_ITEM_TRANSITION); + } + + @Test + public void replaceMediaItems_replacingCurrentItemWithEmptyListAndSubsequentItem_correctMasking() + throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(MediaTestUtils.createTimeline(3)) + .setCurrentMediaItemIndex(1) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(2); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(getEventsAsList(onEventsRef.get())) + .containsExactly( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_MEDIA_ITEM_TRANSITION); + } + + @Test + public void + replaceMediaItems_replacingCurrentItemWithEmptyListAndNoSubsequentItem_correctMasking() + throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(MediaTestUtils.createTimeline(2)) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_BUFFERING) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicInteger playbackStateRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + playbackStateRef.set(controller.getPlaybackState()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(1); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(0); + assertThat(playbackStateRef.get()).isEqualTo(Player.STATE_ENDED); + assertThat(getEventsAsList(onEventsRef.get())) + .containsExactly( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_POSITION_DISCONTINUITY, + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_PLAYBACK_STATE_CHANGED); + } + + @Test + public void replaceMediaItems_fromPreparedEmpty_correctMasking() throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(Timeline.EMPTY) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_ENDED) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicInteger playbackStateRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 0, /* toIndex= */ 0, createMediaItems(2)); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + playbackStateRef.set(controller.getPlaybackState()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(2); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(playbackStateRef.get()).isEqualTo(Player.STATE_BUFFERING); + assertThat(getEventsAsList(onEventsRef.get())) + .containsExactly( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_PLAYBACK_STATE_CHANGED); + } + + @Test + public void replaceMediaItems_fromEmptyToEmpty_correctMasking() throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(Timeline.EMPTY) + .setCurrentMediaItemIndex(1) + .setPlaybackState(Player.STATE_ENDED) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicInteger playbackStateRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 0, /* toIndex= */ 0, ImmutableList.of()); + newTimelineRef.set(controller.getCurrentTimeline()); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + playbackStateRef.set(controller.getPlaybackState()); + latch.countDown(); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().isEmpty()).isTrue(); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(playbackStateRef.get()).isEqualTo(Player.STATE_ENDED); + } + + @Test + public void replaceMediaItems_withInvalidToIndex_correctMasking() throws Exception { + Bundle playerConfig = + new RemoteMediaSession.MockPlayerConfigBuilder() + .setTimeline(MediaTestUtils.createTimeline(3)) + .setCurrentMediaItemIndex(2) + .build(); + remoteSession.setPlayer(playerConfig); + MediaController controller = controllerTestRule.createController(remoteSession.getToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference newTimelineRef = new AtomicReference<>(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged(Timeline timeline, int reason) { + newTimelineRef.set(timeline); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 5000, createMediaItems(2)); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(newTimelineRef.get().getWindowCount()).isEqualTo(3); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(getEventsAsList(onEventsRef.get())) + .containsExactly( + Player.EVENT_TIMELINE_CHANGED, + Player.EVENT_MEDIA_ITEM_TRANSITION, + Player.EVENT_POSITION_DISCONTINUITY); + } + private void assertMoveMediaItems( int initialMediaItemCount, int initialMediaItemIndex, diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingWithMediaSessionCompatTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingWithMediaSessionCompatTest.java index 4fb1de36af..f98ca356c7 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingWithMediaSessionCompatTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaControllerStateMaskingWithMediaSessionCompatTest.java @@ -1307,4 +1307,380 @@ public class MediaControllerStateMaskingWithMediaSessionCompatTest { assertThat(itemsAfterMove).containsExactly(items.get(1), items.get(0)).inOrder(); } + + @Test + public void replaceMediaItems_notReplacingCurrentItem_correctMasking() throws Exception { + List mediaItems = MediaTestUtils.createMediaItems("a", "b", "c"); + List queue = MediaTestUtils.convertToQueueItemsWithoutBitmap(mediaItems); + long testPosition = 200L; + int initialMediaItemIndex = 2; + MediaItem testCurrentMediaItem = mediaItems.get(initialMediaItemIndex); + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PAUSED, testPosition, /* playbackSpeed= */ 1.0f) + .setActiveQueueItemId(queue.get(initialMediaItemIndex).getQueueId()) + .build()); + session.setQueue(queue); + List newMediaItems = MediaTestUtils.createMediaItems("A", "B"); + List expectedMediaItems = new ArrayList<>(); + expectedMediaItems.add(mediaItems.get(0)); + expectedMediaItems.addAll(newMediaItems); + expectedMediaItems.add(mediaItems.get(2)); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(3); // 2x onTimelineChanged + onEvents + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, newMediaItems); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), expectedMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(3); + assertThat(currentMediaItemRef.get()).isEqualTo(testCurrentMediaItem); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), expectedMediaItems); + } + + @Test + public void replaceMediaItems_replacingCurrentItem_correctMasking() throws Exception { + List mediaItems = MediaTestUtils.createMediaItems("a", "b", "c"); + List queue = MediaTestUtils.convertToQueueItemsWithoutBitmap(mediaItems); + long testPosition = 200L; + int initialMediaItemIndex = 1; + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PAUSED, testPosition, /* playbackSpeed= */ 1.0f) + .setActiveQueueItemId(queue.get(initialMediaItemIndex).getQueueId()) + .build()); + session.setQueue(queue); + List newMediaItems = MediaTestUtils.createMediaItems("A", "B"); + List expectedMediaItems = new ArrayList<>(); + expectedMediaItems.add(mediaItems.get(0)); + expectedMediaItems.addAll(newMediaItems); + expectedMediaItems.add(mediaItems.get(2)); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(3); // 2x onTimelineChanged + onEvents + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, newMediaItems); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), expectedMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(currentMediaItemRef.get()).isEqualTo(newMediaItems.get(0)); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), expectedMediaItems); + } + + @Test + public void replaceMediaItems_replacingCurrentItemWithEmptyListAndSubsequentItem_correctMasking() + throws Exception { + List mediaItems = MediaTestUtils.createMediaItems("a", "b", "c"); + List queue = MediaTestUtils.convertToQueueItemsWithoutBitmap(mediaItems); + long testPosition = 200L; + int initialMediaItemIndex = 1; + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PAUSED, testPosition, /* playbackSpeed= */ 1.0f) + .setActiveQueueItemId(queue.get(initialMediaItemIndex).getQueueId()) + .build()); + session.setQueue(queue); + List expectedMediaItems = new ArrayList<>(); + expectedMediaItems.add(mediaItems.get(0)); + expectedMediaItems.add(mediaItems.get(2)); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), expectedMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(currentMediaItemRef.get()).isEqualTo(expectedMediaItems.get(1)); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), expectedMediaItems); + } + + @Test + public void + replaceMediaItems_replacingCurrentItemWithEmptyListAndNoSubsequentItem_correctMasking() + throws Exception { + List mediaItems = MediaTestUtils.createMediaItems("a", "b"); + List queue = MediaTestUtils.convertToQueueItemsWithoutBitmap(mediaItems); + long testPosition = 200L; + int initialMediaItemIndex = 1; + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PAUSED, testPosition, /* playbackSpeed= */ 1.0f) + .setActiveQueueItemId(queue.get(initialMediaItemIndex).getQueueId()) + .build()); + session.setQueue(queue); + List expectedMediaItems = new ArrayList<>(); + expectedMediaItems.add(mediaItems.get(0)); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems( + /* fromIndex= */ 1, /* toIndex= */ 2, ImmutableList.of()); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), expectedMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(0); + assertThat(currentMediaItemRef.get()).isEqualTo(expectedMediaItems.get(0)); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), expectedMediaItems); + } + + @Test + public void replaceMediaItems_fromEmpty_correctMasking() throws Exception { + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState( + PlaybackStateCompat.STATE_STOPPED, /* position= */ 0, /* playbackSpeed= */ 1.0f) + .build()); + session.setQueue(ImmutableList.of()); + List newMediaItems = MediaTestUtils.createMediaItems("A", "B"); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(2); + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems(/* fromIndex= */ 0, /* toIndex= */ 0, newMediaItems); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), newMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(0); + assertThat(currentMediaItemRef.get()).isEqualTo(newMediaItems.get(0)); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), newMediaItems); + } + + @Test + public void replaceMediaItems_withInvalidToIndex_correctMasking() throws Exception { + List mediaItems = MediaTestUtils.createMediaItems("a", "b", "c"); + List queue = MediaTestUtils.convertToQueueItemsWithoutBitmap(mediaItems); + long testPosition = 200L; + int initialMediaItemIndex = 1; + session.setPlaybackState( + new PlaybackStateCompat.Builder() + .setState(PlaybackStateCompat.STATE_PAUSED, testPosition, /* playbackSpeed= */ 1.0f) + .setActiveQueueItemId(queue.get(initialMediaItemIndex).getQueueId()) + .build()); + session.setQueue(queue); + List newMediaItems = MediaTestUtils.createMediaItems("A", "B"); + List expectedMediaItems = new ArrayList<>(); + expectedMediaItems.add(mediaItems.get(0)); + expectedMediaItems.addAll(newMediaItems); + Events expectedEvents = + new Events(new FlagSet.Builder().addAll(EVENT_TIMELINE_CHANGED).build()); + MediaController controller = controllerTestRule.createController(session.getSessionToken()); + CountDownLatch latch = new CountDownLatch(3); + AtomicReference timelineFromParamRef = new AtomicReference<>(); + AtomicInteger timelineChangedReasonRef = new AtomicInteger(); + AtomicReference onEventsRef = new AtomicReference<>(); + Player.Listener listener = + new Player.Listener() { + @Override + public void onTimelineChanged( + Timeline timeline, @Player.TimelineChangeReason int reason) { + timelineFromParamRef.set(timeline); + timelineChangedReasonRef.set(reason); + latch.countDown(); + } + + @Override + public void onEvents(Player player, Player.Events events) { + onEventsRef.set(events); + latch.countDown(); + } + }; + threadTestRule.getHandler().postAndSync(() -> controller.addListener(listener)); + AtomicInteger currentMediaItemIndexRef = new AtomicInteger(); + AtomicReference currentMediaItemRef = new AtomicReference<>(); + AtomicReference timelineFromGetterRef = new AtomicReference<>(); + + threadTestRule + .getHandler() + .postAndSync( + () -> { + controller.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 5000, newMediaItems); + currentMediaItemIndexRef.set(controller.getCurrentMediaItemIndex()); + currentMediaItemRef.set(controller.getCurrentMediaItem()); + timelineFromGetterRef.set(controller.getCurrentTimeline()); + }); + + assertThat(latch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); + MediaTestUtils.assertTimelineContains(timelineFromParamRef.get(), expectedMediaItems); + assertThat(timelineChangedReasonRef.get()).isEqualTo(TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + assertThat(onEventsRef.get()).isEqualTo(expectedEvents); + assertThat(currentMediaItemIndexRef.get()).isEqualTo(1); + assertThat(currentMediaItemRef.get()).isEqualTo(newMediaItems.get(0)); + MediaTestUtils.assertTimelineContains(timelineFromGetterRef.get(), expectedMediaItems); + } } diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPermissionTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPermissionTest.java index baee7aa5d4..01d0636cbe 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPermissionTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPermissionTest.java @@ -168,6 +168,21 @@ public class MediaSessionPermissionTest { controller -> controller.removeMediaItems(/* fromIndex= */ 0, /* toIndex= */ 1)); } + @Test + public void replaceMediaItem() throws Exception { + testOnCommandRequest( + COMMAND_CHANGE_MEDIA_ITEMS, + controller -> controller.replaceMediaItem(/* index= */ 0, MediaItem.EMPTY)); + } + + @Test + public void replaceMediaItems() throws Exception { + testOnCommandRequest( + COMMAND_CHANGE_MEDIA_ITEMS, + controller -> + controller.replaceMediaItems(/* fromIndex= */ 0, /* toIndex= */ 1, ImmutableList.of())); + } + @Test public void setDeviceVolume() throws Exception { testOnCommandRequest(COMMAND_SET_DEVICE_VOLUME, controller -> controller.setDeviceVolume(0)); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPlayerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPlayerTest.java index e823adf4f9..b93fd9cff2 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPlayerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MediaSessionPlayerTest.java @@ -703,6 +703,34 @@ public class MediaSessionPlayerTest { assertThat(player.newIndex).isEqualTo(newIndex); } + @Test + public void replaceMediaItem() throws Exception { + player.setMediaItems(MediaTestUtils.createMediaItems(4)); + MediaItem mediaItem = MediaTestUtils.createMediaItem("replaceMediaItem"); + + controller.replaceMediaItem(/* index= */ 2, mediaItem); + + player.awaitMethodCalled(MockPlayer.METHOD_REPLACE_MEDIA_ITEM, TIMEOUT_MS); + assertThat(player.index).isEqualTo(2); + assertThat(player.mediaItems).hasSize(4); + assertThat(player.mediaItems.get(2)).isEqualTo(mediaItem); + } + + @Test + public void replaceMediaItems() throws Exception { + player.setMediaItems(MediaTestUtils.createMediaItems(4)); + List mediaItems = MediaTestUtils.createMediaItems(2); + + controller.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, mediaItems); + + player.awaitMethodCalled(MockPlayer.METHOD_REPLACE_MEDIA_ITEMS, TIMEOUT_MS); + assertThat(player.fromIndex).isEqualTo(1); + assertThat(player.toIndex).isEqualTo(2); + assertThat(player.mediaItems).hasSize(5); + assertThat(player.mediaItems.get(1)).isEqualTo(mediaItems.get(0)); + assertThat(player.mediaItems.get(2)).isEqualTo(mediaItems.get(1)); + } + @Test public void seekToPreviousMediaItem() throws Exception { controller.seekToPreviousMediaItem(); diff --git a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MockPlayerTest.java b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MockPlayerTest.java index bbb59f68f2..b9f5dc9563 100644 --- a/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MockPlayerTest.java +++ b/libraries/test_session_current/src/androidTest/java/androidx/media3/session/MockPlayerTest.java @@ -370,6 +370,39 @@ public class MockPlayerTest { .inOrder(); } + @Test + public void replaceMediaItem() { + List mediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); + player.addMediaItems(mediaItems); + MediaItem mediaItem = MediaTestUtils.createMediaItem("item"); + + player.replaceMediaItem(/* index= */ 1, mediaItem); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_REPLACE_MEDIA_ITEM)).isTrue(); + assertThat(player.index).isEqualTo(1); + assertThat(player.mediaItems).containsExactly(mediaItems.get(0), mediaItem, mediaItems.get(2)); + } + + @Test + public void replaceMediaItems() { + List mediaItems = MediaTestUtils.createMediaItems(/* size= */ 4); + player.addMediaItems(mediaItems); + List newMediaItems = MediaTestUtils.createMediaItems(/* size= */ 3); + + player.replaceMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, newMediaItems); + + assertThat(player.hasMethodBeenCalled(MockPlayer.METHOD_REPLACE_MEDIA_ITEMS)).isTrue(); + assertThat(player.fromIndex).isEqualTo(1); + assertThat(player.toIndex).isEqualTo(3); + assertThat(player.mediaItems) + .containsExactly( + mediaItems.get(0), + newMediaItems.get(0), + newMediaItems.get(1), + newMediaItems.get(2), + mediaItems.get(3)); + } + @Test public void seekToPreviousMediaItem() { player.seekToPreviousMediaItem(); diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java index 1f5d9b5c16..9545379485 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MediaControllerProviderService.java @@ -463,6 +463,28 @@ public class MediaControllerProviderService extends Service { }); } + @Override + public void replaceMediaItem(String controllerId, int index, Bundle mediaItem) + throws RemoteException { + runOnHandler( + () -> { + MediaController controller = mediaControllerMap.get(controllerId); + controller.replaceMediaItem(index, MediaItem.CREATOR.fromBundle(mediaItem)); + }); + } + + @Override + public void replaceMediaItems( + String controllerId, int fromIndex, int toIndex, List mediaItems) + throws RemoteException { + runOnHandler( + () -> { + MediaController controller = mediaControllerMap.get(controllerId); + controller.replaceMediaItems( + fromIndex, toIndex, BundleableUtil.fromBundleList(MediaItem.CREATOR, mediaItems)); + }); + } + @Override public void seekToPreviousMediaItem(String controllerId) throws RemoteException { runOnHandler( diff --git a/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java b/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java index a4b3cb09cd..6341a5bf89 100644 --- a/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java +++ b/libraries/test_session_current/src/main/java/androidx/media3/session/MockPlayer.java @@ -107,7 +107,9 @@ public class MockPlayer implements Player { METHOD_SET_SHUFFLE_MODE, METHOD_SET_TRACK_SELECTION_PARAMETERS, METHOD_SET_VOLUME, - METHOD_STOP + METHOD_STOP, + METHOD_REPLACE_MEDIA_ITEM, + METHOD_REPLACE_MEDIA_ITEMS }) public @interface Method {} @@ -203,6 +205,10 @@ public class MockPlayer implements Player { public static final int METHOD_SET_DEVICE_MUTED_WITH_FLAGS = 44; /** Maps to {@link Player#setDeviceVolume(int, int)}. */ public static final int METHOD_SET_DEVICE_VOLUME_WITH_FLAGS = 45; + /** Maps to {@link Player#replaceMediaItem(int, MediaItem)}. */ + public static final int METHOD_REPLACE_MEDIA_ITEM = 46; + /** Maps to {@link Player#replaceMediaItems(int, int, List)} . */ + public static final int METHOD_REPLACE_MEDIA_ITEMS = 47; private final boolean changePlayerStateWithTransportControl; private final Looper applicationLooper; @@ -990,6 +996,22 @@ public class MockPlayer implements Player { checkNotNull(conditionVariables.get(METHOD_MOVE_MEDIA_ITEMS)).open(); } + @Override + public void replaceMediaItem(int index, MediaItem mediaItem) { + this.index = index; + this.mediaItems.set(index, mediaItem); + checkNotNull(conditionVariables.get(METHOD_REPLACE_MEDIA_ITEM)).open(); + } + + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + this.fromIndex = fromIndex; + this.toIndex = toIndex; + this.mediaItems.addAll(toIndex, mediaItems); + Util.removeRange(this.mediaItems, fromIndex, toIndex); + checkNotNull(conditionVariables.get(METHOD_REPLACE_MEDIA_ITEMS)).open(); + } + /** * @deprecated Use {@link #hasPreviousMediaItem()} instead. */ @@ -1410,6 +1432,8 @@ public class MockPlayer implements Player { .put(METHOD_SET_TRACK_SELECTION_PARAMETERS, new ConditionVariable()) .put(METHOD_SET_VOLUME, new ConditionVariable()) .put(METHOD_STOP, new ConditionVariable()) + .put(METHOD_REPLACE_MEDIA_ITEM, new ConditionVariable()) + .put(METHOD_REPLACE_MEDIA_ITEMS, new ConditionVariable()) .buildOrThrow(); } 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 c3bf2b6600..18449ba820 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 @@ -218,6 +218,16 @@ public class RemoteMediaController { binder.moveMediaItems(controllerId, fromIndex, toIndex, newIndex); } + public void replaceMediaItem(int index, MediaItem mediaItem) throws RemoteException { + binder.replaceMediaItem(controllerId, index, mediaItem.toBundle()); + } + + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) + throws RemoteException { + binder.replaceMediaItems( + controllerId, fromIndex, toIndex, BundleableUtil.toBundleList(mediaItems)); + } + public void seekToPreviousMediaItem() throws RemoteException { binder.seekToPreviousMediaItem(controllerId); } diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java index d6d6ed9c9d..1f18f7945c 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/StubPlayer.java @@ -102,6 +102,11 @@ public class StubPlayer extends BasePlayer { throw new UnsupportedOperationException(); } + @Override + public void replaceMediaItems(int fromIndex, int toIndex, List mediaItems) { + throw new UnsupportedOperationException(); + } + @Override public void removeMediaItems(int fromIndex, int toIndex) { throw new UnsupportedOperationException();