From e7b76354b9156013b8a7723f07dce271e53e1089 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 14 Jul 2020 21:04:30 +0100 Subject: [PATCH] Add Player.EventListener.onMediaItemTransition PiperOrigin-RevId: 321218451 --- RELEASENOTES.md | 12 +- .../android/exoplayer2/ExoPlayerImpl.java | 82 ++++- .../com/google/android/exoplayer2/Player.java | 35 ++ .../analytics/AnalyticsCollector.java | 10 + .../analytics/AnalyticsListener.java | 13 + .../android/exoplayer2/util/EventLogger.java | 32 ++ .../android/exoplayer2/ExoPlayerTest.java | 323 ++++++++++++++++++ .../testutil/ExoPlayerTestRunner.java | 36 +- .../exoplayer2/testutil/FakeTimeline.java | 45 ++- 9 files changed, 578 insertions(+), 10 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 348357937f..9d34211b1d 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -31,9 +31,10 @@ * Add `play` and `pause` methods to `Player`. * Add `Player.getCurrentLiveOffset` to conveniently return the live offset. - * Add `Player.onPlayWhenReadyChanged` with reasons. - * Add `Player.onPlaybackStateChanged` and deprecate - `Player.onPlayerStateChanged`. + * Add `Player.EventListener.onPlayWhenReadyChanged` with reasons. + * Add `Player.EventListener.onPlaybackStateChanged` and deprecate + `Player.EventListener.onPlayerStateChanged`. + * Add `Player.EventListener.onMediaItemTransition` with reasons. * Add `Player.setAudioSessionId` to set the session ID attached to the `AudioTrack`. * Deprecate and rename `getPlaybackError` to `getPlayerError` for @@ -242,9 +243,8 @@ * Cast extension: Implement playlist API and deprecate the old queue manipulation API. * IMA extension: - * Upgrade to IMA SDK 3.19.4, bringing in a fix for setting the - media load timeout - ([#7170](https://github.com/google/ExoPlayer/issues/7170)). + * Upgrade to IMA SDK 3.19.4, bringing in a fix for setting the media load + timeout ([#7170](https://github.com/google/ExoPlayer/issues/7170)). * Migrate to new 'friendly obstruction' IMA SDK APIs, and allow apps to register a purpose and detail reason for overlay views via `AdsLoader.AdViewProvider`. 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 8482a584d2..4520054b90 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 @@ -990,6 +990,22 @@ import java.util.concurrent.TimeoutException; // Assign playback info immediately such that all getters return the right values. PlaybackInfo previousPlaybackInfo = this.playbackInfo; this.playbackInfo = playbackInfo; + + Pair mediaItemTransitionInfo = + evaluateMediaItemTransitionReason( + playbackInfo, + previousPlaybackInfo, + positionDiscontinuity, + positionDiscontinuityReason, + !previousPlaybackInfo.timeline.equals(playbackInfo.timeline)); + boolean mediaItemTransitioned = mediaItemTransitionInfo.first; + int mediaItemTransitionReason = mediaItemTransitionInfo.second; + @Nullable MediaItem newMediaItem = null; + if (mediaItemTransitioned && !playbackInfo.timeline.isEmpty()) { + int windowIndex = + playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + newMediaItem = playbackInfo.timeline.getWindow(windowIndex, window).mediaItem; + } notifyListeners( new PlaybackInfoUpdate( playbackInfo, @@ -999,10 +1015,58 @@ import java.util.concurrent.TimeoutException; positionDiscontinuity, positionDiscontinuityReason, timelineChangeReason, + mediaItemTransitioned, + mediaItemTransitionReason, + newMediaItem, playWhenReadyChangeReason, seekProcessed)); } + private Pair evaluateMediaItemTransitionReason( + PlaybackInfo playbackInfo, + PlaybackInfo oldPlaybackInfo, + boolean positionDiscontinuity, + int positionDiscontinuityReason, + boolean timelineChanged) { + + Timeline oldTimeline = oldPlaybackInfo.timeline; + Timeline newTimeline = playbackInfo.timeline; + if (newTimeline.isEmpty() && oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } else if (newTimeline.isEmpty() != oldTimeline.isEmpty()) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + int oldWindowIndex = + oldTimeline.getPeriodByUid(oldPlaybackInfo.periodId.periodUid, period).windowIndex; + Object oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid; + int newWindowIndex = + newTimeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; + Object newWindowUid = newTimeline.getWindow(newWindowIndex, window).uid; + int firstPeriodIndexInNewWindow = window.firstPeriodIndex; + if (!oldWindowUid.equals(newWindowUid)) { + @Player.MediaItemTransitionReason int transitionReason; + if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_AUTO; + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_SEEK) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_SEEK; + } else if (timelineChanged) { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED; + } else { + transitionReason = MEDIA_ITEM_TRANSITION_REASON_SKIP; + } + return new Pair<>(/* isTransitioning */ true, transitionReason); + } else if (positionDiscontinuity + && positionDiscontinuityReason == DISCONTINUITY_REASON_PERIOD_TRANSITION + && newTimeline.getIndexOfPeriod(playbackInfo.periodId.periodUid) + == firstPeriodIndexInNewWindow) { + return new Pair<>(/* isTransitioning */ true, MEDIA_ITEM_TRANSITION_REASON_REPEAT); + } + return new Pair<>(/* isTransitioning */ false, /* mediaItemTransitionReason */ C.INDEX_UNSET); + } + private void setMediaSourcesInternal( List mediaSources, int startWindowIndex, @@ -1388,16 +1452,19 @@ import java.util.concurrent.TimeoutException; private final boolean positionDiscontinuity; @DiscontinuityReason private final int positionDiscontinuityReason; @TimelineChangeReason private final int timelineChangeReason; + private final boolean mediaItemTransitioned; + private final int mediaItemTransitionReason; + @Nullable private final MediaItem mediaItem; @PlayWhenReadyChangeReason private final int playWhenReadyChangeReason; private final boolean seekProcessed; private final boolean playbackStateChanged; private final boolean playbackErrorChanged; - private final boolean timelineChanged; private final boolean isLoadingChanged; + private final boolean timelineChanged; private final boolean trackSelectorResultChanged; - private final boolean isPlayingChanged; private final boolean playWhenReadyChanged; private final boolean playbackSuppressionReasonChanged; + private final boolean isPlayingChanged; public PlaybackInfoUpdate( PlaybackInfo playbackInfo, @@ -1407,6 +1474,9 @@ import java.util.concurrent.TimeoutException; boolean positionDiscontinuity, @DiscontinuityReason int positionDiscontinuityReason, @TimelineChangeReason int timelineChangeReason, + boolean mediaItemTransitioned, + @MediaItemTransitionReason int mediaItemTransitionReason, + @Nullable MediaItem mediaItem, @PlayWhenReadyChangeReason int playWhenReadyChangeReason, boolean seekProcessed) { this.playbackInfo = playbackInfo; @@ -1415,6 +1485,9 @@ import java.util.concurrent.TimeoutException; this.positionDiscontinuity = positionDiscontinuity; this.positionDiscontinuityReason = positionDiscontinuityReason; this.timelineChangeReason = timelineChangeReason; + this.mediaItemTransitioned = mediaItemTransitioned; + this.mediaItemTransitionReason = mediaItemTransitionReason; + this.mediaItem = mediaItem; this.playWhenReadyChangeReason = playWhenReadyChangeReason; this.seekProcessed = seekProcessed; playbackStateChanged = previousPlaybackInfo.playbackState != playbackInfo.playbackState; @@ -1444,6 +1517,11 @@ import java.util.concurrent.TimeoutException; listenerSnapshot, listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); } + if (mediaItemTransitioned) { + invokeAll( + listenerSnapshot, + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } if (playbackErrorChanged) { invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError)); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index 47b93e0120..49f50466d2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Player.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Player.java @@ -470,6 +470,15 @@ public interface Player { default void onTimelineChanged( Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a different media item. + * + * @param mediaItem The {@link MediaItem}. May be null if the timeline becomes empty. + * @param reason The reason for the transition. + */ + default void onMediaItemTransition( + @Nullable MediaItem mediaItem, @MediaItemTransitionReason int reason) {} + /** * Called when the available or selected tracks change. * @@ -766,6 +775,32 @@ public interface Player { /** Timeline changed as a result of a dynamic update introduced by the played media. */ int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; + /** Reasons for media item transitions. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + MEDIA_ITEM_TRANSITION_REASON_REPEAT, + MEDIA_ITEM_TRANSITION_REASON_AUTO, + MEDIA_ITEM_TRANSITION_REASON_SEEK, + MEDIA_ITEM_TRANSITION_REASON_SKIP, + MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED + }) + @interface MediaItemTransitionReason {} + /** The media item has been repeated. */ + int MEDIA_ITEM_TRANSITION_REASON_REPEAT = 0; + /** Playback has automatically transitioned to the next media item. */ + int MEDIA_ITEM_TRANSITION_REASON_AUTO = 1; + /** A seek to another media item has occurred. */ + int MEDIA_ITEM_TRANSITION_REASON_SEEK = 2; + /** Playback skipped to a new media item (for example after failure). */ + int MEDIA_ITEM_TRANSITION_REASON_SKIP = 3; + /** + * The current media item has changed because of a modification of the timeline. This can either + * be if the period previously being played has been removed, or when the timeline becomes + * non-empty after being empty. + */ + int MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED = 4; + /** The default playback speed. */ float DEFAULT_PLAYBACK_SPEED = 1.0f; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java index 96359196e0..7fd8273c04 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -22,6 +22,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -455,6 +456,15 @@ public class AnalyticsCollector } } + @Override + public final void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + EventTime eventTime = generateCurrentPlayerMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onMediaItemTransition(eventTime, mediaItem, reason); + } + } + @Override public final void onTracksChanged( TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java index 7fef48154a..1125e60690 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.DiscontinuityReason; @@ -207,6 +208,18 @@ public interface AnalyticsListener { */ default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} + /** + * Called when playback transitions to a different media item. + * + * @param eventTime The event time. + * @param mediaItem The media item. + * @param reason The reason for the media item transition. + */ + default void onMediaItemTransition( + EventTime eventTime, + @Nullable MediaItem mediaItem, + @Player.MediaItemTransitionReason int reason) {} + /** * Called when a position discontinuity occurred. * diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java index 04e10472c9..27b8dd2814 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/EventLogger.java @@ -22,6 +22,7 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player.PlaybackSuppressionReason; @@ -196,6 +197,19 @@ public class EventLogger implements AnalyticsListener { logd("]"); } + @Override + public void onMediaItemTransition( + EventTime eventTime, @Nullable MediaItem mediaItem, int reason) { + logd( + "mediaItem [" + + getEventTimeString(eventTime) + + ", " + + (mediaItem == null ? "null" : "mediaId=" + mediaItem.mediaId) + + ", reason=" + + getMediaItemTransitionReasonString(reason) + + "]"); + } + @Override public void onPlayerError(EventTime eventTime, ExoPlaybackException e) { loge(eventTime, "playerFailed", e); @@ -648,6 +662,24 @@ public class EventLogger implements AnalyticsListener { } } + private static String getMediaItemTransitionReasonString( + @Player.MediaItemTransitionReason int reason) { + switch (reason) { + case Player.MEDIA_ITEM_TRANSITION_REASON_AUTO: + return "AUTO"; + case Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED: + return "PLAYLIST_CHANGED"; + case Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT: + return "REPEAT"; + case Player.MEDIA_ITEM_TRANSITION_REASON_SEEK: + return "SEEK"; + case Player.MEDIA_ITEM_TRANSITION_REASON_SKIP: + return "SKIP"; + default: + return "?"; + } + } + private static String getPlaybackSuppressionReasonString( @PlaybackSuppressionReason int playbackSuppressionReason) { switch (playbackSuppressionReason) { 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 2fe26bddbb..ee9d7668bc 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 @@ -7886,6 +7886,329 @@ public final class ExoPlayerTest { assertThat(initialMediaItems).containsExactlyElementsIn(currentMediaItems); } + @Test + public void setMediaSources_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void setMediaSources_replaceWithSameMediaItem_notifiesMediaItemTransition() + throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource = factory.setTag("1").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .setMediaSources(mediaSource) + .waitForPlaybackState(Player.STATE_READY) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource.getMediaItem(), mediaSource.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void automaticWindowTransition_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .build() + .start() + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void clearMediaItem_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 2000) + .clearMediaItems() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void seekTo_otherWindow_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .waitForPlaybackState(Player.STATE_READY) + .seek(/* windowIndex= */ 1, /* positionMs= */ 2000) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK); + } + + @Test + public void seekTo_sameWindow_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .seek(/* windowIndex= */ 0, /* positionMs= */ 20_000) + .stop() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void repeat_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_ONE); + } + }) + .play() + .waitForPositionDiscontinuity() + .waitForPositionDiscontinuity() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setRepeatMode(Player.REPEAT_MODE_OFF); + } + }) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame( + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource1.getMediaItem(), + mediaSource2.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT, + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO); + } + + @Test + public void stop_withReset_notifiesMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ true) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem(), null); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED, + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void stop_withoutReset_doesNotNotifyMediaItemTransition() throws Exception { + SilenceMediaSource.Factory factory = + new SilenceMediaSource.Factory().setDurationUs(C.msToUs(100_000)); + SilenceMediaSource mediaSource1 = factory.setTag("1").createMediaSource(); + SilenceMediaSource mediaSource2 = factory.setTag("2").createMediaSource(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .stop(/* reset= */ false) + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertMediaItemsTransitionedSame(mediaSource1.getMediaItem()); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + } + + @Test + public void timelineRefresh_withModifiedMediaItem_doesNotNotifyMediaItemTransition() + throws Exception { + MediaItem initialMediaItem = FakeTimeline.FAKE_MEDIA_ITEM.buildUpon().setTag(0).build(); + TimelineWindowDefinition initialWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem); + TimelineWindowDefinition secondWindow = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* isPlaceholder= */ false, + /* durationUs= */ 10_000_000, + /* defaultPositionUs= */ 0, + /* windowOffsetInFirstPeriodUs= */ 0, + AdPlaybackState.NONE, + initialMediaItem.buildUpon().setTag(1).build()); + FakeTimeline timeline = new FakeTimeline(initialWindow); + FakeTimeline newTimeline = new FakeTimeline(secondWindow); + FakeMediaSource mediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder(TAG) + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .waitForPlayWhenReady(false) + .executeRunnable( + () -> { + mediaSource.setNewSourceInfo(newTimeline); + }) + .play() + .build(); + + ExoPlayerTestRunner exoPlayerTestRunner = + new ExoPlayerTestRunner.Builder(context) + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build() + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertTimelinesSame(placeholderTimeline, timeline, newTimeline); + exoPlayerTestRunner.assertMediaItemsTransitionReasonsEqual( + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertMediaItemsTransitionedSame(initialMediaItem); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java index 2869c5b0f2..a28f6ee81b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RenderersFactory; @@ -356,6 +357,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private final CountDownLatch actionScheduleFinishedCountDownLatch; private final ArrayList timelines; private final ArrayList timelineChangeReasons; + private final ArrayList mediaItems; + private final ArrayList mediaItemTransitionReasons; private final ArrayList periodIndices; private final ArrayList discontinuityReasons; private final ArrayList playbackStates; @@ -387,6 +390,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc this.analyticsListener = analyticsListener; timelines = new ArrayList<>(); timelineChangeReasons = new ArrayList<>(); + mediaItems = new ArrayList<>(); + mediaItemTransitionReasons = new ArrayList<>(); periodIndices = new ArrayList<>(); discontinuityReasons = new ArrayList<>(); playbackStates = new ArrayList<>(); @@ -525,12 +530,34 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder(); } + /** + * Asserts that the media items reported by {@link + * Player.EventListener#onMediaItemTransition(MediaItem, int)} are the same as the provided media + * items. + * + * @param mediaItems A list of expected {@link MediaItem media items}. + */ + public void assertMediaItemsTransitionedSame(MediaItem... mediaItems) { + assertThat(this.mediaItems).containsExactlyElementsIn(mediaItems).inOrder(); + } + + /** + * Asserts that the media item transition reasons reported by {@link + * Player.EventListener#onMediaItemTransition(MediaItem, int)} are the same as the provided + * reasons. + * + * @param reasons A list of expected transition reasons. + */ + public void assertMediaItemsTransitionReasonsEqual(Integer... reasons) { + assertThat(this.mediaItemTransitionReasons).containsExactlyElementsIn(reasons).inOrder(); + } + /** * Asserts that the playback states reported by {@link * Player.EventListener#onPlaybackStateChanged(int)} are equal to the provided playback states. */ public void assertPlaybackStatesEqual(Integer... states) { - assertThat(playbackStates).containsExactlyElementsIn(Arrays.asList(states)).inOrder(); + assertThat(playbackStates).containsExactlyElementsIn(states).inOrder(); } /** @@ -617,6 +644,13 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc } } + @Override + public void onMediaItemTransition( + @Nullable MediaItem mediaItem, @Player.MediaItemTransitionReason int reason) { + mediaItems.add(mediaItem); + mediaItemTransitionReasons.add(reason); + } + @Override public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { this.trackGroups = trackGroups; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 2d64d2f637..f1f0e9203b 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -166,10 +166,53 @@ public final class FakeTimeline extends Timeline { long defaultPositionUs, long windowOffsetInFirstPeriodUs, AdPlaybackState adPlaybackState) { + this( + periodCount, + id, + isSeekable, + isDynamic, + isLive, + isPlaceholder, + durationUs, + defaultPositionUs, + windowOffsetInFirstPeriodUs, + adPlaybackState, + FAKE_MEDIA_ITEM.buildUpon().setTag(id).build()); + } + + /** + * Creates a window definition with ad groups and a custom media item. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param isLive Whether the window is live. + * @param isPlaceholder Whether the window is a placeholder. + * @param durationUs The duration of the window in microseconds. + * @param defaultPositionUs The default position of the window in microseconds. + * @param windowOffsetInFirstPeriodUs The offset of the window in the first period, in + * microseconds. + * @param adPlaybackState The ad playback state. + * @param mediaItem The media item to include in the timeline. + */ + public TimelineWindowDefinition( + int periodCount, + Object id, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + boolean isPlaceholder, + long durationUs, + long defaultPositionUs, + long windowOffsetInFirstPeriodUs, + AdPlaybackState adPlaybackState, + MediaItem mediaItem) { Assertions.checkArgument(durationUs != C.TIME_UNSET || periodCount == 1); this.periodCount = periodCount; this.id = id; - this.mediaItem = FAKE_MEDIA_ITEM.buildUpon().setTag(id).build(); + this.mediaItem = mediaItem; this.isSeekable = isSeekable; this.isDynamic = isDynamic; this.isLive = isLive;