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<>();