diff --git a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java
index a3105192e52..e77a654b5bb 100644
--- a/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java
+++ b/library/common/src/main/java/com/google/android/exoplayer2/BasePlayer.java
@@ -21,6 +21,7 @@
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.util.Util;
import com.google.common.collect.ImmutableList;
+import com.google.errorprone.annotations.ForOverride;
import java.util.List;
/** Abstract base {@link Player} which implements common implementation independent methods. */
@@ -185,7 +186,12 @@ public final void seekToPreviousWindow() {
@Override
public final void seekToPreviousMediaItem() {
int previousMediaItemIndex = getPreviousMediaItemIndex();
- if (previousMediaItemIndex != C.INDEX_UNSET) {
+ if (previousMediaItemIndex == C.INDEX_UNSET) {
+ return;
+ }
+ if (previousMediaItemIndex == getCurrentMediaItemIndex()) {
+ repeatCurrentMediaItem();
+ } else {
seekToDefaultPosition(previousMediaItemIndex);
}
}
@@ -252,7 +258,12 @@ public final void seekToNextWindow() {
@Override
public final void seekToNextMediaItem() {
int nextMediaItemIndex = getNextMediaItemIndex();
- if (nextMediaItemIndex != C.INDEX_UNSET) {
+ if (nextMediaItemIndex == C.INDEX_UNSET) {
+ return;
+ }
+ if (nextMediaItemIndex == getCurrentMediaItemIndex()) {
+ repeatCurrentMediaItem();
+ } else {
seekToDefaultPosition(nextMediaItemIndex);
}
}
@@ -424,6 +435,17 @@ public final long getContentDuration() {
: timeline.getWindow(getCurrentMediaItemIndex(), window).getDurationMs();
}
+ /**
+ * Repeat the current media item.
+ *
+ *
The default implementation seeks to the default position in the current item, which can be
+ * overridden for additional handling.
+ */
+ @ForOverride
+ protected void repeatCurrentMediaItem() {
+ seekToDefaultPosition();
+ }
+
private @RepeatMode int getRepeatModeForNavigation() {
@RepeatMode int repeatMode = getRepeatMode();
return repeatMode == REPEAT_MODE_ONE ? REPEAT_MODE_OFF : repeatMode;
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
index cca1d552926..c2b9bb4a8f5 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java
@@ -531,7 +531,8 @@ public void prepare() {
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -652,7 +653,8 @@ public void addMediaSources(int index, List mediaSources) {
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -670,7 +672,8 @@ public void removeMediaItems(int fromIndex, int toIndex) {
positionDiscontinuity,
DISCONTINUITY_REASON_REMOVE,
/* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo),
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -700,7 +703,8 @@ public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) {
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -724,7 +728,8 @@ public void setShuffleOrder(ShuffleOrder shuffleOrder) {
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -803,47 +808,17 @@ public boolean isLoading() {
return playbackInfo.isLoading;
}
+ @Override
+ protected void repeatCurrentMediaItem() {
+ verifyApplicationThread();
+ seekToInternal(
+ getCurrentMediaItemIndex(), /* positionMs= */ C.TIME_UNSET, /* repeatMediaItem= */ true);
+ }
+
@Override
public void seekTo(int mediaItemIndex, long positionMs) {
verifyApplicationThread();
- analyticsCollector.notifySeekStarted();
- Timeline timeline = playbackInfo.timeline;
- if (mediaItemIndex < 0
- || (!timeline.isEmpty() && mediaItemIndex >= timeline.getWindowCount())) {
- throw new IllegalSeekPositionException(timeline, mediaItemIndex, positionMs);
- }
- pendingOperationAcks++;
- if (isPlayingAd()) {
- // TODO: Investigate adding support for seeking during ads. This is complicated to do in
- // general because the midroll ad preceding the seek destination must be played before the
- // content position can be played, if a different ad is playing at the moment.
- Log.w(TAG, "seekTo ignored because an ad is playing");
- ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate =
- new ExoPlayerImplInternal.PlaybackInfoUpdate(this.playbackInfo);
- playbackInfoUpdate.incrementPendingOperationAcks(1);
- playbackInfoUpdateListener.onPlaybackInfoUpdate(playbackInfoUpdate);
- return;
- }
- @Player.State
- int newPlaybackState =
- getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : STATE_BUFFERING;
- int oldMaskingMediaItemIndex = getCurrentMediaItemIndex();
- PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackState(newPlaybackState);
- newPlaybackInfo =
- maskTimelineAndPosition(
- newPlaybackInfo,
- timeline,
- maskWindowPositionMsOrGetPeriodPositionUs(timeline, mediaItemIndex, positionMs));
- internalPlayer.seekTo(timeline, mediaItemIndex, Util.msToUs(positionMs));
- updatePlaybackInfo(
- newPlaybackInfo,
- /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
- /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
- /* seekProcessed= */ true,
- /* positionDiscontinuity= */ true,
- /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK,
- /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo),
- oldMaskingMediaItemIndex);
+ seekToInternal(mediaItemIndex, positionMs, /* repeatMediaItem= */ false);
}
@Override
@@ -884,7 +859,8 @@ public void setPlaybackParameters(PlaybackParameters playbackParameters) {
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
@Override
@@ -1733,7 +1709,8 @@ private void stopInternal(boolean reset, @Nullable ExoPlaybackException error) {
positionDiscontinuity,
DISCONTINUITY_REASON_REMOVE,
/* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(playbackInfo),
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
private int getCurrentWindowIndexInternal() {
@@ -1815,7 +1792,8 @@ private void handlePlaybackInfo(ExoPlayerImplInternal.PlaybackInfoUpdate playbac
positionDiscontinuity,
pendingDiscontinuityReason,
discontinuityWindowStartPositionUs,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
}
@@ -1829,7 +1807,8 @@ private void updatePlaybackInfo(
boolean positionDiscontinuity,
@DiscontinuityReason int positionDiscontinuityReason,
long discontinuityWindowStartPositionUs,
- int oldMaskingMediaItemIndex) {
+ int oldMaskingMediaItemIndex,
+ boolean repeatCurrentMediaItem) {
// Assign playback info immediately such that all getters return the right values, but keep
// snapshot of previous and new state so that listener invocations are triggered correctly.
@@ -1837,13 +1816,15 @@ private void updatePlaybackInfo(
PlaybackInfo newPlaybackInfo = playbackInfo;
this.playbackInfo = playbackInfo;
+ boolean timelineChanged = !previousPlaybackInfo.timeline.equals(newPlaybackInfo.timeline);
Pair mediaItemTransitionInfo =
evaluateMediaItemTransitionReason(
newPlaybackInfo,
previousPlaybackInfo,
positionDiscontinuity,
positionDiscontinuityReason,
- !previousPlaybackInfo.timeline.equals(newPlaybackInfo.timeline));
+ timelineChanged,
+ repeatCurrentMediaItem);
boolean mediaItemTransitioned = mediaItemTransitionInfo.first;
int mediaItemTransitionReason = mediaItemTransitionInfo.second;
MediaMetadata newMediaMetadata = mediaMetadata;
@@ -1880,7 +1861,7 @@ private void updatePlaybackInfo(
updatePriorityTaskManagerForIsLoadingChange(newPlaybackInfo.isLoading);
}
- if (!previousPlaybackInfo.timeline.equals(newPlaybackInfo.timeline)) {
+ if (timelineChanged) {
listeners.queueEvent(
Player.EVENT_TIMELINE_CHANGED,
listener -> listener.onTimelineChanged(newPlaybackInfo.timeline, timelineChangeReason));
@@ -2083,7 +2064,8 @@ private Pair evaluateMediaItemTransitionReason(
PlaybackInfo oldPlaybackInfo,
boolean positionDiscontinuity,
@DiscontinuityReason int positionDiscontinuityReason,
- boolean timelineChanged) {
+ boolean timelineChanged,
+ boolean repeatCurrentMediaItem) {
Timeline oldTimeline = oldPlaybackInfo.timeline;
Timeline newTimeline = playbackInfo.timeline;
@@ -2114,11 +2096,20 @@ private Pair evaluateMediaItemTransitionReason(
throw new IllegalStateException();
}
return new Pair<>(/* isTransitioning */ true, transitionReason);
- } else if (positionDiscontinuity
- && positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION
- && oldPlaybackInfo.periodId.windowSequenceNumber
- < playbackInfo.periodId.windowSequenceNumber) {
- return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_REPEAT);
+ } else {
+ // Only mark changes within the current item as a transition if we are repeating automatically
+ // or via a seek to next/previous.
+ if (positionDiscontinuity
+ && positionDiscontinuityReason == DISCONTINUITY_REASON_AUTO_TRANSITION
+ && oldPlaybackInfo.periodId.windowSequenceNumber
+ < playbackInfo.periodId.windowSequenceNumber) {
+ return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_REPEAT);
+ }
+ if (positionDiscontinuity
+ && positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK
+ && repeatCurrentMediaItem) {
+ return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_SEEK);
+ }
}
return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET);
}
@@ -2189,7 +2180,8 @@ private void setMediaSourcesInternal(
/* positionDiscontinuity= */ positionDiscontinuity,
DISCONTINUITY_REASON_REMOVE,
/* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo),
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
private List addMediaSourceHolders(
@@ -2581,7 +2573,8 @@ private void updatePlayWhenReady(
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ C.TIME_UNSET,
- /* ignored */ C.INDEX_UNSET);
+ /* ignored */ C.INDEX_UNSET,
+ /* repeatCurrentMediaItem= */ false);
}
private void updateWakeAndWifiLock() {
@@ -2679,6 +2672,48 @@ private void updatePriorityTaskManagerForIsLoadingChange(boolean isLoading) {
}
}
+ private void seekToInternal(int mediaItemIndex, long positionMs, boolean repeatMediaItem) {
+ analyticsCollector.notifySeekStarted();
+ Timeline timeline = playbackInfo.timeline;
+ if (mediaItemIndex < 0
+ || (!timeline.isEmpty() && mediaItemIndex >= timeline.getWindowCount())) {
+ throw new IllegalSeekPositionException(timeline, mediaItemIndex, positionMs);
+ }
+ pendingOperationAcks++;
+ if (isPlayingAd()) {
+ // TODO: Investigate adding support for seeking during ads. This is complicated to do in
+ // general because the midroll ad preceding the seek destination must be played before the
+ // content position can be played, if a different ad is playing at the moment.
+ Log.w(TAG, "seekTo ignored because an ad is playing");
+ ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate =
+ new ExoPlayerImplInternal.PlaybackInfoUpdate(this.playbackInfo);
+ playbackInfoUpdate.incrementPendingOperationAcks(1);
+ playbackInfoUpdateListener.onPlaybackInfoUpdate(playbackInfoUpdate);
+ return;
+ }
+ @Player.State
+ int newPlaybackState =
+ getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : STATE_BUFFERING;
+ int oldMaskingMediaItemIndex = getCurrentMediaItemIndex();
+ PlaybackInfo newPlaybackInfo = playbackInfo.copyWithPlaybackState(newPlaybackState);
+ newPlaybackInfo =
+ maskTimelineAndPosition(
+ newPlaybackInfo,
+ timeline,
+ maskWindowPositionMsOrGetPeriodPositionUs(timeline, mediaItemIndex, positionMs));
+ internalPlayer.seekTo(timeline, mediaItemIndex, Util.msToUs(positionMs));
+ updatePlaybackInfo(
+ newPlaybackInfo,
+ /* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
+ /* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
+ /* seekProcessed= */ true,
+ /* positionDiscontinuity= */ true,
+ /* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK,
+ /* discontinuityWindowStartPositionUs= */ getCurrentPositionUsInternal(newPlaybackInfo),
+ oldMaskingMediaItemIndex,
+ repeatMediaItem);
+ }
+
private static DeviceInfo createDeviceInfo(StreamVolumeManager streamVolumeManager) {
return new DeviceInfo(
DeviceInfo.PLAYBACK_TYPE_LOCAL,
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
index 43af7172bb6..1c1662ea972 100644
--- a/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
+++ b/library/core/src/test/java/com/google/android/exoplayer2/ExoPlayerTest.java
@@ -8811,6 +8811,39 @@ public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) {
player.release();
}
+ @Test
+ public void seekToNextPrevious_singleItemRepeat_notifiesMediaItemTransition() throws Exception {
+ List reportedMediaItems = new ArrayList<>();
+ List reportedTransitionReasons = new ArrayList<>();
+ MediaSource mediaSource = FakeMediaSource.createWithWindowId(/* windowId= */ new Object());
+ ExoPlayer player = new TestExoPlayerBuilder(context).build();
+ player.addListener(
+ new Listener() {
+ @Override
+ public void onMediaItemTransition(@Nullable MediaItem mediaItem, int reason) {
+ reportedMediaItems.add(mediaItem);
+ reportedTransitionReasons.add(reason);
+ }
+ });
+ player.setMediaSource(mediaSource);
+ player.prepare();
+ runUntilPlaybackState(player, Player.STATE_READY);
+
+ player.setRepeatMode(Player.REPEAT_MODE_ALL);
+ player.seekToNextMediaItem();
+ player.seekToPreviousMediaItem();
+ player.release();
+
+ MediaItem expectedMediaItem = mediaSource.getMediaItem();
+ assertThat(reportedMediaItems)
+ .containsExactly(expectedMediaItem, expectedMediaItem, expectedMediaItem);
+ assertThat(reportedTransitionReasons)
+ .containsExactly(
+ Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED,
+ Player.MEDIA_ITEM_TRANSITION_REASON_SEEK,
+ Player.MEDIA_ITEM_TRANSITION_REASON_SEEK);
+ }
+
@Test
public void repeat_notifiesMediaItemTransition() throws Exception {
List reportedMediaItems = new ArrayList<>();