Commit 6a9cb2cb by tonihei Committed by Tofunmi Adigun-Hameed

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
(cherry picked from commit 6309b11792b05306d004747af35d67f32a352782)
parent 4a125467
...@@ -320,6 +320,18 @@ public final class CastPlayer extends BasePlayer { ...@@ -320,6 +320,18 @@ public final class CastPlayer extends BasePlayer {
} }
@Override @Override
public void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> 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) { public void removeMediaItems(int fromIndex, int toIndex) {
checkArgument(fromIndex >= 0 && toIndex >= fromIndex); checkArgument(fromIndex >= 0 && toIndex >= fromIndex);
int playlistSize = currentTimeline.getWindowCount(); int playlistSize = currentTimeline.getWindowCount();
......
...@@ -699,6 +699,38 @@ public class CastPlayerTest { ...@@ -699,6 +699,38 @@ public class CastPlayerTest {
.queueRemoveItems(new int[] {1, 2, 3, 4, 5}, /* customData= */ null); .queueRemoveItems(new int[] {1, 2, 3, 4, 5}, /* customData= */ null);
} }
@Test
public void replaceMediaItems_callsRemoteMediaClient() {
int[] mediaQueueItemIds = createMediaQueueItemIds(/* numberOfIds= */ 2);
List<MediaItem> 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<MediaItem> 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") @SuppressWarnings("ConstantConditions")
@Test @Test
public void addMediaItems_fillsTimeline() { public void addMediaItems_fillsTimeline() {
......
...@@ -77,6 +77,12 @@ public abstract class BasePlayer implements Player { ...@@ -77,6 +77,12 @@ public abstract class BasePlayer implements Player {
} }
@Override @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) { public final void removeMediaItem(int index) {
removeMediaItems(/* fromIndex= */ index, /* toIndex= */ index + 1); removeMediaItems(/* fromIndex= */ index, /* toIndex= */ index + 1);
} }
......
...@@ -41,7 +41,6 @@ import com.google.android.exoplayer2.util.Size; ...@@ -41,7 +41,6 @@ import com.google.android.exoplayer2.util.Size;
import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.Util;
import com.google.android.exoplayer2.video.VideoSize; import com.google.android.exoplayer2.video.VideoSize;
import com.google.common.base.Objects; import com.google.common.base.Objects;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.lang.annotation.Documented; import java.lang.annotation.Documented;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
...@@ -2147,10 +2146,7 @@ public interface Player { ...@@ -2147,10 +2146,7 @@ public interface Player {
* of the playlist, the request is ignored. * of the playlist, the request is ignored.
* @param mediaItem The new {@link MediaItem}. * @param mediaItem The new {@link MediaItem}.
*/ */
default void replaceMediaItem(int index, MediaItem mediaItem) { void replaceMediaItem(int index, MediaItem mediaItem);
replaceMediaItems(
/* fromIndex= */ index, /* toIndex= */ index + 1, ImmutableList.of(mediaItem));
}
/** /**
* Replaces the media items at the given range of the playlist. * Replaces the media items at the given range of the playlist.
...@@ -2169,10 +2165,7 @@ public interface Player { ...@@ -2169,10 +2165,7 @@ public interface Player {
* larger than the size of the playlist, items up to the end of the playlist are replaced. * 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. * @param mediaItems The {@linkplain MediaItem media items} to replace the range with.
*/ */
default void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems) { void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems);
addMediaItems(toIndex, mediaItems);
removeMediaItems(fromIndex, toIndex);
}
/** /**
* Removes the media item at the given index of the playlist. * Removes the media item at the given index of the playlist.
......
...@@ -2145,15 +2145,42 @@ public abstract class SimpleBasePlayer extends BasePlayer { ...@@ -2145,15 +2145,42 @@ public abstract class SimpleBasePlayer extends BasePlayer {
} }
@Override @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<MediaItem> mediaItems) { public final void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems) {
addMediaItems(toIndex, mediaItems); verifyApplicationThreadAndInitState();
removeMediaItems(fromIndex, toIndex); 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<MediaItemData> 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 @Override
...@@ -3186,6 +3213,27 @@ public abstract class SimpleBasePlayer extends BasePlayer { ...@@ -3186,6 +3213,27 @@ public abstract class SimpleBasePlayer extends BasePlayer {
} }
/** /**
* Handles calls to {@link Player#replaceMediaItem} and {@link Player#replaceMediaItems}.
*
* <p>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 &lt;=
* {@code fromIndex} &lt; {@link #getMediaItemCount()}.
* @param toIndex The index of the first item not to be replaced (exclusive). The index is in the
* range {@code fromIndex} &lt; {@code toIndex} &lt;= {@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<MediaItem> 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}. * Handles calls to {@link Player#removeMediaItem} and {@link Player#removeMediaItems}.
* *
* <p>Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available. * <p>Will only be called if {@link Player#COMMAND_CHANGE_MEDIA_ITEMS} is available.
......
...@@ -6875,6 +6875,559 @@ public class SimpleBasePlayerTest { ...@@ -6875,6 +6875,559 @@ public class SimpleBasePlayerTest {
assertThat(callForwarded.get()).isFalse(); 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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<MediaItem> 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. @SuppressWarnings("deprecation") // Verifying deprecated listener calls.
@Test @Test
public void seekTo_immediateHandling_updatesStateAndInformsListeners() { public void seekTo_immediateHandling_updatesStateAndInformsListeners() {
......
...@@ -722,6 +722,38 @@ import java.util.concurrent.TimeoutException; ...@@ -722,6 +722,38 @@ import java.util.concurrent.TimeoutException;
} }
@Override @Override
public void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems) {
verifyApplicationThread();
checkArgument(fromIndex >= 0 && toIndex >= fromIndex);
int playlistSize = mediaSourceHolderSnapshots.size();
if (fromIndex > playlistSize) {
// Do nothing.
return;
}
toIndex = min(toIndex, playlistSize);
List<MediaSource> 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) { public void setShuffleOrder(ShuffleOrder shuffleOrder) {
verifyApplicationThread(); verifyApplicationThread();
this.shuffleOrder = shuffleOrder; this.shuffleOrder = shuffleOrder;
......
...@@ -934,6 +934,12 @@ public class SimpleExoPlayer extends BasePlayer ...@@ -934,6 +934,12 @@ public class SimpleExoPlayer extends BasePlayer
} }
@Override @Override
public void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems) {
blockUntilConstructorFinished();
player.replaceMediaItems(fromIndex, toIndex, mediaItems);
}
@Override
public void removeMediaItems(int fromIndex, int toIndex) { public void removeMediaItems(int fromIndex, int toIndex) {
blockUntilConstructorFinished(); blockUntilConstructorFinished();
player.removeMediaItems(fromIndex, toIndex); player.removeMediaItems(fromIndex, toIndex);
......
...@@ -85,6 +85,7 @@ import static org.mockito.Mockito.never; ...@@ -85,6 +85,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset; import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.robolectric.Shadows.shadowOf; import static org.robolectric.Shadows.shadowOf;
import android.content.Context; import android.content.Context;
...@@ -12641,6 +12642,232 @@ public final class ExoPlayerTest { ...@@ -12641,6 +12642,232 @@ public final class ExoPlayerTest {
eventsInOrder.verify(mockListener).onPlayerError(any(), any()); 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. // Internal methods.
private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) {
......
...@@ -101,6 +101,11 @@ public class StubPlayer extends BasePlayer { ...@@ -101,6 +101,11 @@ public class StubPlayer extends BasePlayer {
} }
@Override @Override
public void replaceMediaItems(int fromIndex, int toIndex, List<MediaItem> mediaItems) {
throw new UnsupportedOperationException();
}
@Override
public void removeMediaItems(int fromIndex, int toIndex) { public void removeMediaItems(int fromIndex, int toIndex) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment