From 36fa9d5a434812ef637ad27a0a9f1c4dd9651b1b Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 17 Dec 2019 19:21:27 +0000 Subject: [PATCH] add top-level playlist API Design doc: https://docs.google.com/document/d/11h0S91KI5TB3NNZUtsCzg0S7r6nyTnF_tDZZAtmY93g/edit Issue: #6161, #5155 PiperOrigin-RevId: 286020313 --- RELEASENOTES.md | 1 + .../exoplayer2/demo/PlayerActivity.java | 59 +- .../exoplayer2/ext/cast/CastPlayer.java | 14 +- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 2 +- .../google/android/exoplayer2/ExoPlayer.java | 171 +- .../android/exoplayer2/ExoPlayerFactory.java | 10 +- .../android/exoplayer2/ExoPlayerImpl.java | 537 ++- .../exoplayer2/ExoPlayerImplInternal.java | 586 +++- .../android/exoplayer2/MediaPeriodHolder.java | 24 +- .../android/exoplayer2/MediaPeriodQueue.java | 7 +- .../android/exoplayer2/PlaybackInfo.java | 7 +- .../com/google/android/exoplayer2/Player.java | 25 +- .../android/exoplayer2/SimpleExoPlayer.java | 195 +- .../analytics/AnalyticsCollector.java | 17 +- .../android/exoplayer2/util/EventLogger.java | 10 +- .../android/exoplayer2/ExoPlayerTest.java | 3044 +++++++++++++++-- .../exoplayer2/MediaPeriodQueueTest.java | 123 +- .../analytics/AnalyticsCollectorTest.java | 101 +- .../android/exoplayer2/testutil/Action.java | 324 +- .../exoplayer2/testutil/ActionSchedule.java | 168 +- .../testutil/ExoPlayerTestRunner.java | 156 +- .../exoplayer2/testutil/FakeMediaSource.java | 31 +- .../exoplayer2/testutil/StubExoPlayer.java | 88 + 23 files changed, 4722 insertions(+), 978 deletions(-) diff --git a/RELEASENOTES.md b/RELEASENOTES.md index a1becd899e8..ac5ba1b045a 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -34,6 +34,7 @@ ([6773](https://github.com/google/ExoPlayer/issues/6773)). * Suppress ProGuard warnings for compile-time `javax.annotation` package ([#6771](https://github.com/google/ExoPlayer/issues/6771)). +* Add playlist API ([#6161](https://github.com/google/ExoPlayer/issues/6161)). ### 2.11.0 (2019-12-11) ### diff --git a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java index b291d5afe8d..b759c97da5c 100644 --- a/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java +++ b/demos/main/src/main/java/com/google/android/exoplayer2/demo/PlayerActivity.java @@ -51,7 +51,6 @@ import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; -import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.MergingMediaSource; @@ -82,6 +81,8 @@ import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; +import java.util.ArrayList; +import java.util.List; /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity @@ -147,7 +148,7 @@ public class PlayerActivity extends AppCompatActivity private DataSource.Factory dataSourceFactory; private SimpleExoPlayer player; - private MediaSource mediaSource; + private List mediaSources; private DefaultTrackSelector trackSelector; private DefaultTrackSelector.Parameters trackSelectorParameters; private DebugTextViewHelper debugViewHelper; @@ -349,12 +350,10 @@ public void onVisibilityChange(int visibility) { private void initializePlayer() { if (player == null) { Intent intent = getIntent(); - - mediaSource = createTopLevelMediaSource(intent); - if (mediaSource == null) { + mediaSources = createTopLevelMediaSources(intent); + if (mediaSources.isEmpty()) { return; } - TrackSelection.Factory trackSelectionFactory; String abrAlgorithm = intent.getStringExtra(ABR_ALGORITHM_EXTRA); if (abrAlgorithm == null || ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) { @@ -395,13 +394,12 @@ private void initializePlayer() { if (haveStartPosition) { player.seekTo(startWindow, startPosition); } - player.setMediaSource(mediaSource); + player.setMediaSources(mediaSources, /* resetPosition= */ !haveStartPosition); player.prepare(); updateButtonVisibility(); } - @Nullable - private MediaSource createTopLevelMediaSource(Intent intent) { + private List createTopLevelMediaSources(Intent intent) { String action = intent.getAction(); boolean actionIsListView = ACTION_VIEW_LIST.equals(action); if (!actionIsListView && !ACTION_VIEW.equals(action)) { @@ -429,10 +427,10 @@ private MediaSource createTopLevelMediaSource(Intent intent) { } } - MediaSource[] mediaSources = new MediaSource[samples.length]; - for (int i = 0; i < samples.length; i++) { - mediaSources[i] = createLeafMediaSource(samples[i]); - Sample.SubtitleInfo subtitleInfo = samples[i].subtitleInfo; + List mediaSources = new ArrayList<>(); + for (UriSample sample : samples) { + MediaSource mediaSource = createLeafMediaSource(sample); + Sample.SubtitleInfo subtitleInfo = sample.subtitleInfo; if (subtitleInfo != null) { Format subtitleFormat = Format.createTextSampleFormat( @@ -443,33 +441,30 @@ private MediaSource createTopLevelMediaSource(Intent intent) { MediaSource subtitleMediaSource = new SingleSampleMediaSource.Factory(dataSourceFactory) .createMediaSource(subtitleInfo.uri, subtitleFormat, C.TIME_UNSET); - mediaSources[i] = new MergingMediaSource(mediaSources[i], subtitleMediaSource); + mediaSource = new MergingMediaSource(mediaSource, subtitleMediaSource); } + mediaSources.add(mediaSource); } - MediaSource mediaSource = - mediaSources.length == 1 ? mediaSources[0] : new ConcatenatingMediaSource(mediaSources); - - if (seenAdsTagUri) { + if (seenAdsTagUri && mediaSources.size() == 1) { Uri adTagUri = samples[0].adTagUri; - if (actionIsListView) { - showToast(R.string.unsupported_ads_in_concatenation); + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; + } + MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri); + if (adsMediaSource != null) { + mediaSources.set(0, adsMediaSource); } else { - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - MediaSource adsMediaSource = createAdsMediaSource(mediaSource, adTagUri); - if (adsMediaSource != null) { - mediaSource = adsMediaSource; - } else { - showToast(R.string.ima_not_loaded); - } + showToast(R.string.ima_not_loaded); } + } else if (seenAdsTagUri && mediaSources.size() > 1) { + showToast(R.string.unsupported_ads_in_concatenation); + releaseAdsLoader(); } else { releaseAdsLoader(); } - return mediaSource; + return mediaSources; } private MediaSource createLeafMediaSource(UriSample parameters) { @@ -557,7 +552,7 @@ private void releasePlayer() { debugViewHelper = null; player.release(); player = null; - mediaSource = null; + mediaSources = null; trackSelector = null; } if (adsLoader != null) { diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index c198b49777f..5b91410ff95 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -110,7 +110,6 @@ public final class CastPlayer extends BasePlayer { private int pendingSeekCount; private int pendingSeekWindowIndex; private long pendingSeekPositionMs; - private boolean waitingForInitialTimeline; /** * @param castContext The context from which the cast session is obtained. @@ -173,7 +172,6 @@ public PendingResult loadItems( MediaQueueItem[] items, int startIndex, long positionMs, @RepeatMode int repeatMode) { if (remoteMediaClient != null) { positionMs = positionMs != C.TIME_UNSET ? positionMs : 0; - waitingForInitialTimeline = true; return remoteMediaClient.queueLoad(items, startIndex, getCastRepeatMode(repeatMode), positionMs, null); } @@ -641,15 +639,13 @@ private void updateRepeatModeAndNotifyIfChanged(@Nullable ResultCallback resu private void updateTimelineAndNotifyIfChanged() { if (updateTimeline()) { - @Player.TimelineChangeReason - int reason = - waitingForInitialTimeline - ? Player.TIMELINE_CHANGE_REASON_PREPARED - : Player.TIMELINE_CHANGE_REASON_DYNAMIC; - waitingForInitialTimeline = false; + // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and + // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553]. notificationsBatch.add( new ListenerNotificationTask( - listener -> listener.onTimelineChanged(currentTimeline, reason))); + listener -> + listener.onTimelineChanged( + currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE))); } } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java index 9e1f8848c35..2452da474d5 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -288,7 +288,7 @@ public void onAdPlaybackState(AdPlaybackState adPlaybackState) { this.adPlaybackState = adPlaybackState; fakeExoPlayer.updateTimeline( new SinglePeriodAdTimeline(contentTimeline, adPlaybackState), - Player.TIMELINE_CHANGE_REASON_DYNAMIC); + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java index 0374c04cb3b..b35f6170487 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -28,6 +28,7 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; @@ -39,6 +40,7 @@ import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; +import java.util.List; /** * An extensible media player that plays {@link MediaSource}s. Instances can be obtained from {@link @@ -139,7 +141,7 @@ final class Builder { private LoadControl loadControl; private BandwidthMeter bandwidthMeter; private Looper looper; - private AnalyticsCollector analyticsCollector; + @Nullable private AnalyticsCollector analyticsCollector; private boolean useLazyPreparation; private boolean buildCalled; @@ -172,7 +174,7 @@ public Builder(Context context, Renderer... renderers) { new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), Util.getLooper(), - new AnalyticsCollector(Clock.DEFAULT), + /* analyticsCollector= */ null, /* useLazyPreparation= */ true, Clock.DEFAULT); } @@ -199,7 +201,7 @@ public Builder( LoadControl loadControl, BandwidthMeter bandwidthMeter, Looper looper, - AnalyticsCollector analyticsCollector, + @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, Clock clock) { Assertions.checkArgument(renderers.length > 0); @@ -335,7 +337,15 @@ public ExoPlayer build() { Assertions.checkState(!buildCalled); buildCalled = true; ExoPlayerImpl player = - new ExoPlayerImpl(renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + new ExoPlayerImpl( + renderers, + trackSelector, + loadControl, + bandwidthMeter, + analyticsCollector, + useLazyPreparation, + clock, + looper); if (releaseTimeoutMs > 0) { player.experimental_setReleaseTimeoutMs(releaseTimeoutMs); @@ -348,58 +358,157 @@ public ExoPlayer build() { /** Returns the {@link Looper} associated with the playback thread. */ Looper getPlaybackLooper(); - /** - * Retries a failed or stopped playback. Does nothing if the player has been reset, or if playback - * has not failed or been stopped. - */ + /** @deprecated Use {@link #prepare()} instead. */ + @Deprecated void retry(); /** Prepares the player. */ void prepare(); - /** - * @deprecated Use {@code setMediaSource(mediaSource, C.TIME_UNSET)} and {@link #prepare()} - * instead. - */ + /** @deprecated Use {@link #setMediaSource(MediaSource)} and {@link #prepare()} instead. */ @Deprecated void prepare(MediaSource mediaSource); - /** @deprecated Use {@link #setMediaSource(MediaSource, long)} and {@link #prepare()} instead. */ + /** + * @deprecated Use {@link #setMediaSource(MediaSource, boolean)} and {@link #prepare()} instead. + */ @Deprecated void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState); /** - * Sets the specified {@link MediaSource}. - * - *

Note: This is an intermediate implementation towards a larger change. Until then {@link - * #prepare()} has to be called immediately after calling this method. + * Clears the playlist, adds the specified {@link MediaSource MediaSources} and resets the + * position to the default position. * - * @param mediaSource The new {@link MediaSource}. + * @param mediaSources The new {@link MediaSource MediaSources}. */ - void setMediaSource(MediaSource mediaSource); + void setMediaSources(List mediaSources); /** - * Sets the specified {@link MediaSource}. + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. * - *

Note: This is an intermediate implementation towards a larger change. Until then {@link - * #prepare()} has to be called immediately after calling this method. + * @param mediaSources The new {@link MediaSource MediaSources}. + * @param resetPosition Whether the playback position should be reset to the default position in + * the first {@link Timeline.Window}. If false, playback will start from the position defined + * by {@link #getCurrentWindowIndex()} and {@link #getCurrentPosition()}. + */ + void setMediaSources(List mediaSources, boolean resetPosition); + + /** + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. * - *

This intermediate implementation calls {@code stop(true)} before seeking to avoid seeking in - * a media item that has been set previously. It is equivalent with calling + * @param mediaSources The new {@link MediaSource MediaSources}. + * @param startWindowIndex The window index to start playback from. If {@link C#INDEX_UNSET} is + * passed, the current position is not reset. + * @param startPositionMs The position in milliseconds to start playback from. If {@link + * C#TIME_UNSET} is passed, the default position of the given window is used. In any case, if + * {@code startWindowIndex} is set to {@link C#INDEX_UNSET}, this parameter is ignored and the + * position is not reset at all. + */ + void setMediaSources(List mediaSources, int startWindowIndex, long startPositionMs); + + /** + * Clears the playlist, adds the specified {@link MediaSource} and resets the position to the + * default position. * - *


-   *   if (!getCurrentTimeline().isEmpty()) {
-   *     player.stop(true);
-   *   }
-   *   player.seekTo(0, startPositionMs);
-   *   player.setMediaSource(mediaSource);
-   * 
+ * @param mediaSource The new {@link MediaSource}. + */ + void setMediaSource(MediaSource mediaSource); + + /** + * Clears the playlist and adds the specified {@link MediaSource}. * * @param mediaSource The new {@link MediaSource}. * @param startPositionMs The position in milliseconds to start playback from. */ void setMediaSource(MediaSource mediaSource, long startPositionMs); + /** + * Clears the playlist and adds the specified {@link MediaSource}. + * + * @param mediaSource The new {@link MediaSource}. + * @param resetPosition Whether the playback position should be reset to the default position. If + * false, playback will start from the position defined by {@link #getCurrentWindowIndex()} + * and {@link #getCurrentPosition()}. + */ + void setMediaSource(MediaSource mediaSource, boolean resetPosition); + + /** + * Adds a media source to the end of the playlist. + * + * @param mediaSource The {@link MediaSource} to add. + */ + void addMediaSource(MediaSource mediaSource); + + /** + * Adds a media source at the given index of the playlist. + * + * @param index The index at which to add the source. + * @param mediaSource The {@link MediaSource} to add. + */ + void addMediaSource(int index, MediaSource mediaSource); + + /** + * Adds a list of media sources to the end of the playlist. + * + * @param mediaSources The {@link MediaSource MediaSources} to add. + */ + void addMediaSources(List mediaSources); + + /** + * Adds a list of media sources at the given index of the playlist. + * + * @param index The index at which to add the media sources. + * @param mediaSources The {@link MediaSource MediaSources} to add. + */ + void addMediaSources(int index, List mediaSources); + + /** + * Moves the media item at the current index to the new index. + * + * @param currentIndex The current index of the media item to move. + * @param newIndex The new index of the media item. If the new index is larger than the size of + * the playlist the item is moved to the end of the playlist. + */ + void moveMediaItem(int currentIndex, int newIndex); + + /** + * Moves the media item range to the new index. + * + * @param fromIndex The start of the range to move. + * @param toIndex The first item not to be included in the range (exclusive). + * @param newIndex The new index of the first media item of the range. If the new index is larger + * than the size of the remaining playlist after removing the range, the range is moved to the + * end of the playlist. + */ + void moveMediaItems(int fromIndex, int toIndex, int newIndex); + + /** + * Removes the media item at the given index of the playlist. + * + * @param index The index at which to remove the media item. + * @return The removed {@link MediaSource} or null if no item exists at the given index. + */ + @Nullable + MediaSource removeMediaItem(int index); + + /** + * Removes a range of media items from the playlist. + * + * @param fromIndex The index at which to start removing media items. + * @param toIndex The index of the first item to be kept (exclusive). + */ + void removeMediaItems(int fromIndex, int toIndex); + + /** Clears the playlist. */ + void clearMediaItems(); + + /** + * Sets the shuffle order. + * + * @param shuffleOrder The shuffle order. + */ + void setShuffleOrder(ShuffleOrder shuffleOrder); + /** * Creates a message that can be sent to a {@link PlayerMessage.Target}. By default, the message * will be delivered immediately without blocking on the playback thread. The default {@link diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java index e4f239df77a..add82f2f7eb 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -297,6 +297,7 @@ public static SimpleExoPlayer newSimpleInstance( drmSessionManager, bandwidthMeter, analyticsCollector, + /* useLazyPreparation= */ true, Clock.DEFAULT, looper); } @@ -345,6 +346,13 @@ public static ExoPlayer newInstance( BandwidthMeter bandwidthMeter, Looper looper) { return new ExoPlayerImpl( - renderers, trackSelector, loadControl, bandwidthMeter, Clock.DEFAULT, looper); + renderers, + trackSelector, + loadControl, + bandwidthMeter, + /* analyticsCollector= */ null, + /* useLazyPreparation= */ true, + Clock.DEFAULT, + looper); } } 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 98eaaa0c2c3..97ba989c347 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 @@ -22,8 +22,10 @@ import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.PlayerMessage.Target; +import com.google.android.exoplayer2.analytics.AnalyticsCollector; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -35,6 +37,9 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.TimeoutException; @@ -62,19 +67,20 @@ private final CopyOnWriteArrayList listeners; private final Timeline.Period period; private final ArrayDeque pendingListenerNotifications; + private final List mediaSourceHolders; + private final boolean useLazyPreparation; - @Nullable private MediaSource mediaSource; private boolean playWhenReady; @PlaybackSuppressionReason private int playbackSuppressionReason; @RepeatMode private int repeatMode; private boolean shuffleModeEnabled; private int pendingOperationAcks; - private boolean hasPendingPrepare; private boolean hasPendingSeek; private boolean foregroundMode; private int pendingSetPlaybackParametersAcks; private PlaybackParameters playbackParameters; private SeekParameters seekParameters; + private ShuffleOrder shuffleOrder; // Playback information when there is no pending seek/set source operation. private PlaybackInfo playbackInfo; @@ -91,6 +97,10 @@ * @param trackSelector The {@link TrackSelector} that will be used by the instance. * @param loadControl The {@link LoadControl} that will be used by the instance. * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. + * @param analyticsCollector The {@link AnalyticsCollector} that will be used by the instance. + * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest + * loads and other initial preparation steps happen immediately. If true, these initial + * preparations are triggered only when the player starts buffering the media. * @param clock The {@link Clock} that will be used by the instance. * @param looper The {@link Looper} which must be used for all calls to the player and which is * used to call listeners on. @@ -101,6 +111,8 @@ public ExoPlayerImpl( TrackSelector trackSelector, LoadControl loadControl, BandwidthMeter bandwidthMeter, + @Nullable AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" @@ -108,10 +120,13 @@ public ExoPlayerImpl( Assertions.checkState(renderers.length > 0); this.renderers = Assertions.checkNotNull(renderers); this.trackSelector = Assertions.checkNotNull(trackSelector); - this.playWhenReady = false; - this.repeatMode = Player.REPEAT_MODE_OFF; - this.shuffleModeEnabled = false; - this.listeners = new CopyOnWriteArrayList<>(); + this.useLazyPreparation = useLazyPreparation; + playWhenReady = false; + repeatMode = Player.REPEAT_MODE_OFF; + shuffleModeEnabled = false; + listeners = new CopyOnWriteArrayList<>(); + mediaSourceHolders = new ArrayList<>(); + shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); emptyTrackSelectorResult = new TrackSelectorResult( new RendererConfiguration[renderers.length], @@ -121,6 +136,7 @@ public ExoPlayerImpl( playbackParameters = PlaybackParameters.DEFAULT; seekParameters = SeekParameters.DEFAULT; playbackSuppressionReason = PLAYBACK_SUPPRESSION_REASON_NONE; + maskingWindowIndex = C.INDEX_UNSET; eventHandler = new Handler(looper) { @Override @@ -130,6 +146,9 @@ public void handleMessage(Message msg) { }; playbackInfo = PlaybackInfo.createDummy(/* startPositionUs= */ 0, emptyTrackSelectorResult); pendingListenerNotifications = new ArrayDeque<>(); + if (analyticsCollector != null) { + analyticsCollector.setPlayer(this); + } internalPlayer = new ExoPlayerImplInternal( renderers, @@ -140,6 +159,7 @@ public void handleMessage(Message msg) { playWhenReady, repeatMode, shuffleModeEnabled, + analyticsCollector, eventHandler, clock); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); @@ -226,45 +246,182 @@ public ExoPlaybackException getPlaybackError() { return playbackInfo.playbackError; } + /** @deprecated Use {@link #prepare()} instead. */ + @Deprecated @Override public void retry() { - if (mediaSource != null && playbackInfo.playbackState == Player.STATE_IDLE) { - prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); - } + prepare(); } @Override + public void prepare() { + if (playbackInfo.playbackState != Player.STATE_IDLE) { + return; + } + PlaybackInfo playbackInfo = + getResetPlaybackInfo( + /* clearPlaylist= */ false, + /* resetError= */ true, + /* playbackState= */ this.playbackInfo.timeline.isEmpty() + ? Player.STATE_ENDED + : Player.STATE_BUFFERING); + // Trigger internal prepare first before updating the playback info and notifying external + // listeners to ensure that new operations issued in the listener notifications reach the + // player after this prepare. The internal player can't change the playback info immediately + // because it uses a callback. + pendingOperationAcks++; + internalPlayer.prepare(); + updatePlaybackInfo( + playbackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ DISCONTINUITY_REASON_INTERNAL, + /* ignored */ TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + /* seekProcessed= */ false); + } + + /** + * @deprecated Use {@link #setMediaSource(MediaSource)} and {@link ExoPlayer#prepare()} instead. + */ @Deprecated + @Override public void prepare(MediaSource mediaSource) { setMediaSource(mediaSource); - prepareInternal(/* resetPosition= */ true, /* resetState= */ true); + prepare(); } - @Override + /** + * @deprecated Use {@link #setMediaSource(MediaSource, boolean)} and {@link ExoPlayer#prepare()} + * instead. + */ @Deprecated + @Override public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { - setMediaSource(mediaSource); - prepareInternal(resetPosition, resetState); + setMediaSource(mediaSource, resetPosition); + prepare(); } @Override - public void prepare() { - Assertions.checkNotNull(mediaSource); - prepareInternal(/* resetPosition= */ false, /* resetState= */ true); + public void setMediaSource(MediaSource mediaSource) { + setMediaSources(Collections.singletonList(mediaSource)); } @Override public void setMediaSource(MediaSource mediaSource, long startPositionMs) { - if (!getCurrentTimeline().isEmpty()) { - stop(/* reset= */ true); + setMediaSources( + Collections.singletonList(mediaSource), /* startWindowIndex= */ 0, startPositionMs); + } + + @Override + public void setMediaSource(MediaSource mediaSource, boolean resetPosition) { + setMediaSources(Collections.singletonList(mediaSource), resetPosition); + } + + @Override + public void setMediaSources(List mediaSources) { + setMediaSources(mediaSources, /* resetPosition= */ true); + } + + @Override + public void setMediaSources(List mediaSources, boolean resetPosition) { + setMediaItemsInternal( + mediaSources, + /* startWindowIndex= */ C.INDEX_UNSET, + /* startPositionMs= */ C.TIME_UNSET, + /* resetToDefaultPosition= */ resetPosition); + } + + @Override + public void setMediaSources( + List mediaSources, int startWindowIndex, long startPositionMs) { + setMediaItemsInternal( + mediaSources, startWindowIndex, startPositionMs, /* resetToDefaultPosition= */ false); + } + + @Override + public void addMediaSource(MediaSource mediaSource) { + addMediaSources(Collections.singletonList(mediaSource)); + } + + @Override + public void addMediaSource(int index, MediaSource mediaSource) { + addMediaSources(index, Collections.singletonList(mediaSource)); + } + + @Override + public void addMediaSources(List mediaSources) { + addMediaSources(/* index= */ mediaSourceHolders.size(), mediaSources); + } + + @Override + public void addMediaSources(int index, List mediaSources) { + Assertions.checkArgument(index >= 0); + int currentWindowIndex = getCurrentWindowIndex(); + long currentPositionMs = getCurrentPosition(); + Timeline oldTimeline = getCurrentTimeline(); + pendingOperationAcks++; + List holders = addMediaSourceHolders(index, mediaSources); + Timeline timeline = + maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + internalPlayer.addMediaSources(index, holders, shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + @Override + public MediaSource removeMediaItem(int index) { + List mediaSourceHolders = + removeMediaItemsInternal(/* fromIndex= */ index, /* toIndex= */ index + 1); + return mediaSourceHolders.isEmpty() ? null : mediaSourceHolders.get(0).mediaSource; + } + + @Override + public void removeMediaItems(int fromIndex, int toIndex) { + Assertions.checkArgument(toIndex > fromIndex); + removeMediaItemsInternal(fromIndex, toIndex); + } + + @Override + public void moveMediaItem(int currentIndex, int newIndex) { + Assertions.checkArgument(currentIndex != newIndex); + moveMediaItems(/* fromIndex= */ currentIndex, /* toIndex= */ currentIndex + 1, newIndex); + } + + @Override + public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { + Assertions.checkArgument( + fromIndex >= 0 + && fromIndex <= toIndex + && toIndex <= mediaSourceHolders.size() + && newFromIndex >= 0); + int currentWindowIndex = getCurrentWindowIndex(); + long currentPositionMs = getCurrentPosition(); + Timeline oldTimeline = getCurrentTimeline(); + pendingOperationAcks++; + newFromIndex = Math.min(newFromIndex, mediaSourceHolders.size() - (toIndex - fromIndex)); + Playlist.moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); + Timeline timeline = + maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + internalPlayer.moveMediaSources(fromIndex, toIndex, newFromIndex, shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + @Override + public void clearMediaItems() { + if (mediaSourceHolders.isEmpty()) { + return; } - seekTo(/* windowIndex= */ 0, startPositionMs); - setMediaSource(mediaSource); + removeMediaItemsInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); } @Override - public void setMediaSource(MediaSource mediaSource) { - this.mediaSource = mediaSource; + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + pendingOperationAcks++; + this.shuffleOrder = shuffleOrder; + Timeline timeline = maskTimeline(); + internalPlayer.setShuffleOrder(shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); } @Override @@ -365,18 +522,7 @@ public void seekTo(int windowIndex, long positionMs) { .sendToTarget(); return; } - maskingWindowIndex = windowIndex; - if (timeline.isEmpty()) { - maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs; - maskingPeriodIndex = 0; - } else { - long windowPositionUs = positionMs == C.TIME_UNSET - ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() : C.msToUs(positionMs); - Pair periodUidAndPosition = - timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); - maskingWindowPositionMs = C.usToMs(windowPositionUs); - maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first); - } + maskWindowIndexAndPositionForSeek(timeline, windowIndex, positionMs); internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs)); notifyListeners(listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)); } @@ -427,13 +573,9 @@ public void setForegroundMode(boolean foregroundMode) { @Override public void stop(boolean reset) { - if (reset) { - mediaSource = null; - } PlaybackInfo playbackInfo = getResetPlaybackInfo( - /* resetPosition= */ reset, - /* resetState= */ reset, + /* clearPlaylist= */ reset, /* resetError= */ reset, /* playbackState= */ Player.STATE_IDLE); // Trigger internal stop first before updating the playback info and notifying external @@ -446,7 +588,7 @@ public void stop(boolean reset) { playbackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, - TIMELINE_CHANGE_REASON_RESET, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* seekProcessed= */ false); } @@ -455,7 +597,6 @@ public void release() { Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); - mediaSource = null; if (!internalPlayer.release()) { notifyListeners( listener -> @@ -466,8 +607,7 @@ public void release() { eventHandler.removeCallbacksAndMessages(null); playbackInfo = getResetPlaybackInfo( - /* resetPosition= */ false, - /* resetState= */ false, + /* clearPlaylist= */ false, /* resetError= */ false, /* playbackState= */ Player.STATE_IDLE); } @@ -493,12 +633,8 @@ public int getCurrentPeriodIndex() { @Override public int getCurrentWindowIndex() { - if (shouldMaskPosition()) { - return maskingWindowIndex; - } else { - return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) - .windowIndex; - } + int currentWindowIndex = getCurrentWindowIndexInternal(); + return currentWindowIndex == C.INDEX_UNSET ? 0 : currentWindowIndex; } @Override @@ -615,10 +751,11 @@ public Timeline getCurrentTimeline() { // Not private so it can be called from an inner class without going through a thunk method. /* package */ void handleEvent(Message msg) { + switch (msg.what) { case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED: handlePlaybackInfo( - (PlaybackInfo) msg.obj, + /* playbackInfo= */ (PlaybackInfo) msg.obj, /* operationAcks= */ msg.arg1, /* positionDiscontinuity= */ msg.arg2 != C.INDEX_UNSET, /* positionDiscontinuityReason= */ msg.arg2); @@ -631,27 +768,13 @@ public Timeline getCurrentTimeline() { } } - /* package */ void prepareInternal(boolean resetPosition, boolean resetState) { - Assertions.checkNotNull(mediaSource); - PlaybackInfo playbackInfo = - getResetPlaybackInfo( - resetPosition, - resetState, - /* resetError= */ true, - /* playbackState= */ Player.STATE_BUFFERING); - // Trigger internal prepare first before updating the playback info and notifying external - // listeners to ensure that new operations issued in the listener notifications reach the - // player after this prepare. The internal player can't change the playback info immediately - // because it uses a callback. - hasPendingPrepare = true; - pendingOperationAcks++; - internalPlayer.prepare(mediaSource, resetPosition, resetState); - updatePlaybackInfo( - playbackInfo, - /* positionDiscontinuity= */ false, - /* ignored */ DISCONTINUITY_REASON_INTERNAL, - TIMELINE_CHANGE_REASON_RESET, - /* seekProcessed= */ false); + private int getCurrentWindowIndexInternal() { + if (shouldMaskPosition()) { + return maskingWindowIndex; + } else { + return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) + .windowIndex; + } } private void handlePlaybackParameters( @@ -685,59 +808,51 @@ private void handlePlaybackInfo( } if (!this.playbackInfo.timeline.isEmpty() && playbackInfo.timeline.isEmpty()) { // Update the masking variables, which are used when the timeline becomes empty. - maskingPeriodIndex = 0; - maskingWindowIndex = 0; - maskingWindowPositionMs = 0; + resetMaskingPosition(); } - @Player.TimelineChangeReason - int timelineChangeReason = - hasPendingPrepare - ? Player.TIMELINE_CHANGE_REASON_PREPARED - : Player.TIMELINE_CHANGE_REASON_DYNAMIC; boolean seekProcessed = hasPendingSeek; - hasPendingPrepare = false; hasPendingSeek = false; updatePlaybackInfo( playbackInfo, positionDiscontinuity, positionDiscontinuityReason, - timelineChangeReason, + TIMELINE_CHANGE_REASON_SOURCE_UPDATE, seekProcessed); } } private PlaybackInfo getResetPlaybackInfo( - boolean resetPosition, - boolean resetState, - boolean resetError, - @Player.State int playbackState) { - if (resetPosition) { - maskingWindowIndex = 0; - maskingPeriodIndex = 0; - maskingWindowPositionMs = 0; + boolean clearPlaylist, boolean resetError, @Player.State int playbackState) { + if (clearPlaylist) { + // Reset list of media source holders which are used for creating the masking timeline. + removeMediaSourceHolders( + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + resetMaskingPosition(); } else { maskingWindowIndex = getCurrentWindowIndex(); maskingPeriodIndex = getCurrentPeriodIndex(); maskingWindowPositionMs = getCurrentPosition(); } - // Also reset period-based PlaybackInfo positions if resetting the state. - resetPosition = resetPosition || resetState; - MediaPeriodId mediaPeriodId = - resetPosition - ? playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period) - : playbackInfo.periodId; - long startPositionUs = resetPosition ? 0 : playbackInfo.positionUs; - long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; + Timeline timeline = playbackInfo.timeline; + MediaPeriodId mediaPeriodId = playbackInfo.periodId; + long contentPositionUs = playbackInfo.contentPositionUs; + long startPositionUs = playbackInfo.positionUs; + if (clearPlaylist) { + timeline = Timeline.EMPTY; + mediaPeriodId = playbackInfo.getDummyPeriodForEmptyTimeline(); + contentPositionUs = C.TIME_UNSET; + startPositionUs = C.TIME_UNSET; + } return new PlaybackInfo( - resetState ? Timeline.EMPTY : playbackInfo.timeline, + timeline, mediaPeriodId, startPositionUs, contentPositionUs, playbackState, resetError ? null : playbackInfo.playbackError, /* isLoading= */ false, - resetState ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, - resetState ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + clearPlaylist ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + clearPlaylist ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, mediaPeriodId, startPositionUs, /* totalBufferedDurationUs= */ 0, @@ -747,8 +862,8 @@ private PlaybackInfo getResetPlaybackInfo( private void updatePlaybackInfo( PlaybackInfo playbackInfo, boolean positionDiscontinuity, - @Player.DiscontinuityReason int positionDiscontinuityReason, - @Player.TimelineChangeReason int timelineChangeReason, + @DiscontinuityReason int positionDiscontinuityReason, + @TimelineChangeReason int timelineChangeReason, boolean seekProcessed) { boolean previousIsPlaying = isPlaying(); // Assign playback info immediately such that all getters return the right values. @@ -769,6 +884,218 @@ private void updatePlaybackInfo( /* isPlayingChanged= */ previousIsPlaying != isPlaying)); } + private void setMediaItemsInternal( + List mediaItems, + int startWindowIndex, + long startPositionMs, + boolean resetToDefaultPosition) { + int currentWindowIndex = getCurrentWindowIndexInternal(); + long currentPositionMs = getCurrentPosition(); + boolean currentPlayWhenReady = getPlayWhenReady(); + pendingOperationAcks++; + if (!mediaSourceHolders.isEmpty()) { + removeMediaSourceHolders( + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + } + List holders = addMediaSourceHolders(/* index= */ 0, mediaItems); + Timeline timeline = maskTimeline(); + if (!timeline.isEmpty() && startWindowIndex >= timeline.getWindowCount()) { + throw new IllegalSeekPositionException(timeline, startWindowIndex, startPositionMs); + } + // Evaluate the actual start position. + if (resetToDefaultPosition) { + startWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + startPositionMs = C.TIME_UNSET; + } else if (startWindowIndex == C.INDEX_UNSET) { + startWindowIndex = currentWindowIndex; + startPositionMs = currentPositionMs; + } + maskWindowIndexAndPositionForSeek( + timeline, startWindowIndex == C.INDEX_UNSET ? 0 : startWindowIndex, startPositionMs); + // mask the playback state + int maskingPlaybackState = playbackInfo.playbackState; + if (startWindowIndex != C.INDEX_UNSET) { + // Position reset to startWindowIndex (results in pending initial seek). + if (timeline.isEmpty() || startWindowIndex >= timeline.getWindowCount()) { + // Setting an empty timeline or invalid seek transitions to ended. + maskingPlaybackState = STATE_ENDED; + } else { + maskingPlaybackState = STATE_BUFFERING; + } + } + boolean playbackStateChanged = + playbackInfo.playbackState != STATE_IDLE + && playbackInfo.playbackState != maskingPlaybackState; + int finalMaskingPlaybackState = maskingPlaybackState; + if (playbackStateChanged) { + playbackInfo = playbackInfo.copyWithPlaybackState(finalMaskingPlaybackState); + } + internalPlayer.setMediaSources( + holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder); + notifyListeners( + listener -> { + listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + if (playbackStateChanged) { + listener.onPlayerStateChanged(currentPlayWhenReady, finalMaskingPlaybackState); + } + }); + } + + private List addMediaSourceHolders( + int index, List mediaSources) { + List holders = new ArrayList<>(); + for (int i = 0; i < mediaSources.size(); i++) { + Playlist.MediaSourceHolder holder = + new Playlist.MediaSourceHolder(mediaSources.get(i), useLazyPreparation); + holders.add(holder); + mediaSourceHolders.add(i + index, holder); + } + shuffleOrder = + shuffleOrder.cloneAndInsert( + /* insertionIndex= */ index, /* insertionCount= */ holders.size()); + return holders; + } + + private List removeMediaItemsInternal(int fromIndex, int toIndex) { + Assertions.checkArgument( + fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolders.size()); + int currentWindowIndex = getCurrentWindowIndex(); + long currentPositionMs = getCurrentPosition(); + boolean currentPlayWhenReady = getPlayWhenReady(); + Timeline oldTimeline = getCurrentTimeline(); + int currentMediaSourceCount = mediaSourceHolders.size(); + pendingOperationAcks++; + List removedHolders = + removeMediaSourceHolders(fromIndex, /* toIndexExclusive= */ toIndex); + Timeline timeline = + maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline); + // Player transitions to STATE_ENDED if the current index is part of the removed tail. + final boolean transitionsToEnded = + playbackInfo.playbackState != STATE_IDLE + && playbackInfo.playbackState != STATE_ENDED + && fromIndex < toIndex + && toIndex == currentMediaSourceCount + && currentWindowIndex >= timeline.getWindowCount(); + if (transitionsToEnded) { + playbackInfo = playbackInfo.copyWithPlaybackState(STATE_ENDED); + } + internalPlayer.removeMediaSources(fromIndex, toIndex, shuffleOrder); + notifyListeners( + listener -> { + listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + if (transitionsToEnded) { + listener.onPlayerStateChanged(currentPlayWhenReady, STATE_ENDED); + } + }); + return removedHolders; + } + + private List removeMediaSourceHolders( + int fromIndex, int toIndexExclusive) { + List removed = new ArrayList<>(); + for (int i = toIndexExclusive - 1; i >= fromIndex; i--) { + removed.add(mediaSourceHolders.remove(i)); + } + shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndexExclusive); + return removed; + } + + private Timeline maskTimeline() { + playbackInfo = + playbackInfo.copyWithTimeline( + mediaSourceHolders.isEmpty() + ? Timeline.EMPTY + : new Playlist.PlaylistTimeline(mediaSourceHolders, shuffleOrder)); + return playbackInfo.timeline; + } + + private Timeline maskTimelineAndWindowIndex( + int currentWindowIndex, long currentPositionMs, Timeline oldTimeline) { + Timeline maskingTimeline = maskTimeline(); + if (oldTimeline.isEmpty()) { + // The index is the default index or was set by a seek in the empty old timeline. + maskingWindowIndex = currentWindowIndex; + if (!maskingTimeline.isEmpty() && currentWindowIndex >= maskingTimeline.getWindowCount()) { + // The seek is not valid in the new timeline. + maskWithDefaultPosition(maskingTimeline); + } + return maskingTimeline; + } + @Nullable + Pair periodPosition = + oldTimeline.getPeriodPosition( + window, + period, + currentWindowIndex, + C.msToUs(currentPositionMs), + /* defaultPositionProjectionUs= */ 0); + Object periodUid = Util.castNonNull(periodPosition).first; + if (maskingTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) { + // Get the window index of the current period that exists in the new timeline also. + maskingWindowIndex = maskingTimeline.getPeriodByUid(periodUid, period).windowIndex; + } else { + // Period uid not found in new timeline. Try to get subsequent period. + @Nullable + Object nextPeriodUid = + ExoPlayerImplInternal.resolveSubsequentPeriod( + window, + period, + repeatMode, + shuffleModeEnabled, + periodUid, + oldTimeline, + maskingTimeline); + if (nextPeriodUid != null) { + // Set masking to the default position of the window of the subsequent period. + maskingWindowIndex = maskingTimeline.getPeriodByUid(nextPeriodUid, period).windowIndex; + maskingPeriodIndex = maskingTimeline.getWindow(maskingWindowIndex, window).firstPeriodIndex; + maskingWindowPositionMs = window.getDefaultPositionMs(); + } else { + // Reset if no subsequent period is found. + maskWithDefaultPosition(maskingTimeline); + } + } + return maskingTimeline; + } + + private void maskWindowIndexAndPositionForSeek( + Timeline timeline, int windowIndex, long positionMs) { + maskingWindowIndex = windowIndex; + if (timeline.isEmpty()) { + maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs; + maskingPeriodIndex = 0; + } else if (windowIndex >= timeline.getWindowCount()) { + // An initial seek now proves to be invalid in the actual timeline. + maskWithDefaultPosition(timeline); + } else { + long windowPositionUs = + positionMs == C.TIME_UNSET + ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() + : C.msToUs(positionMs); + Pair periodUidAndPosition = + timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); + maskingWindowPositionMs = C.usToMs(windowPositionUs); + maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first); + } + } + + private void maskWithDefaultPosition(Timeline timeline) { + if (timeline.isEmpty()) { + resetMaskingPosition(); + return; + } + maskingWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + timeline.getWindow(maskingWindowIndex, window); + maskingWindowPositionMs = window.getDefaultPositionMs(); + maskingPeriodIndex = window.firstPeriodIndex; + } + + private void resetMaskingPosition() { + maskingWindowIndex = C.INDEX_UNSET; + maskingWindowPositionMs = 0; + maskingPeriodIndex = 0; + } + private void notifyListeners(ListenerInvocation listenerInvocation) { CopyOnWriteArrayList listenerSnapshot = new CopyOnWriteArrayList<>(listeners); notifyListeners(() -> invokeAll(listenerSnapshot, listenerInvocation)); @@ -804,7 +1131,7 @@ private static final class PlaybackInfoUpdate implements Runnable { private final TrackSelector trackSelector; private final boolean positionDiscontinuity; private final @Player.DiscontinuityReason int positionDiscontinuityReason; - private final @Player.TimelineChangeReason int timelineChangeReason; + private final int timelineChangeReason; private final boolean seekProcessed; private final boolean playbackStateChanged; private final boolean playbackErrorChanged; @@ -838,15 +1165,15 @@ public PlaybackInfoUpdate( playbackErrorChanged = previousPlaybackInfo.playbackError != playbackInfo.playbackError && playbackInfo.playbackError != null; - timelineChanged = previousPlaybackInfo.timeline != playbackInfo.timeline; isLoadingChanged = previousPlaybackInfo.isLoading != playbackInfo.isLoading; + timelineChanged = !previousPlaybackInfo.timeline.equals(playbackInfo.timeline); trackSelectorResultChanged = previousPlaybackInfo.trackSelectorResult != playbackInfo.trackSelectorResult; } @Override public void run() { - if (timelineChanged || timelineChangeReason == TIMELINE_CHANGE_REASON_PREPARED) { + if (timelineChanged) { invokeAll( listenerSnapshot, listener -> listener.onTimelineChanged(playbackInfo.timeline, timelineChangeReason)); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index 2c6f7631cf6..47f85b603af 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -25,11 +25,11 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.DefaultMediaClock.PlaybackParameterListener; import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.analytics.AnalyticsCollector; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; -import com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; import com.google.android.exoplayer2.source.SampleStream; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; @@ -44,6 +44,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; /** Implements the internal behavior of {@link ExoPlayerImpl}. */ @@ -51,7 +52,7 @@ implements Handler.Callback, MediaPeriod.Callback, TrackSelector.InvalidationListener, - MediaSourceCaller, + Playlist.PlaylistInfoRefreshListener, PlaybackParameterListener, PlayerMessage.Sender { @@ -70,16 +71,21 @@ private static final int MSG_SET_SEEK_PARAMETERS = 5; private static final int MSG_STOP = 6; private static final int MSG_RELEASE = 7; - private static final int MSG_REFRESH_SOURCE_INFO = 8; - private static final int MSG_PERIOD_PREPARED = 9; - private static final int MSG_SOURCE_CONTINUE_LOADING_REQUESTED = 10; - private static final int MSG_TRACK_SELECTION_INVALIDATED = 11; - private static final int MSG_SET_REPEAT_MODE = 12; - private static final int MSG_SET_SHUFFLE_ENABLED = 13; - private static final int MSG_SET_FOREGROUND_MODE = 14; - private static final int MSG_SEND_MESSAGE = 15; - private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 16; - private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 17; + private static final int MSG_PERIOD_PREPARED = 8; + private static final int MSG_SOURCE_CONTINUE_LOADING_REQUESTED = 9; + private static final int MSG_TRACK_SELECTION_INVALIDATED = 10; + private static final int MSG_SET_REPEAT_MODE = 11; + private static final int MSG_SET_SHUFFLE_ENABLED = 12; + private static final int MSG_SET_FOREGROUND_MODE = 13; + private static final int MSG_SEND_MESSAGE = 14; + private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 15; + private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 16; + private static final int MSG_SET_MEDIA_SOURCES = 17; + private static final int MSG_ADD_MEDIA_SOURCES = 18; + private static final int MSG_MOVE_MEDIA_SOURCES = 19; + private static final int MSG_REMOVE_MEDIA_SOURCES = 20; + private static final int MSG_SET_SHUFFLE_ORDER = 21; + private static final int MSG_PLAYLIST_UPDATE_REQUESTED = 22; private static final int ACTIVE_INTERVAL_MS = 10; private static final int IDLE_INTERVAL_MS = 1000; @@ -102,12 +108,12 @@ private final ArrayList pendingMessages; private final Clock clock; private final MediaPeriodQueue queue; + private final Playlist playlist; @SuppressWarnings("unused") private SeekParameters seekParameters; private PlaybackInfo playbackInfo; - private MediaSource mediaSource; private Renderer[] enabledRenderers; private boolean released; private boolean playWhenReady; @@ -117,8 +123,7 @@ private boolean shuffleModeEnabled; private boolean foregroundMode; - private int pendingPrepareCount; - private SeekPosition pendingInitialSeekPosition; + @Nullable private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; private int nextPendingMessageIndex; private boolean deliverPendingMessageAtStartPositionRequired; @@ -134,6 +139,7 @@ public ExoPlayerImplInternal( boolean playWhenReady, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, + @Nullable AnalyticsCollector analyticsCollector, Handler eventHandler, Clock clock) { this.renderers = renderers; @@ -174,16 +180,18 @@ public ExoPlayerImplInternal( internalPlaybackThread.start(); handler = clock.createHandler(internalPlaybackThread.getLooper(), this); deliverPendingMessageAtStartPositionRequired = true; + playlist = new Playlist(this); + if (analyticsCollector != null) { + playlist.setAnalyticsCollector(eventHandler, analyticsCollector); + } } public void experimental_setReleaseTimeoutMs(long releaseTimeoutMs) { this.releaseTimeoutMs = releaseTimeoutMs; } - public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { - handler - .obtainMessage(MSG_PREPARE, resetPosition ? 1 : 0, resetState ? 1 : 0, mediaSource) - .sendToTarget(); + public void prepare() { + handler.obtainMessage(MSG_PREPARE).sendToTarget(); } public void setPlayWhenReady(boolean playWhenReady) { @@ -216,6 +224,50 @@ public void stop(boolean reset) { handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); } + public void setMediaSources( + List mediaSources, + int windowIndex, + long positionUs, + ShuffleOrder shuffleOrder) { + handler + .obtainMessage( + MSG_SET_MEDIA_SOURCES, + new PlaylistUpdateMessage(mediaSources, shuffleOrder, windowIndex, positionUs)) + .sendToTarget(); + } + + public void addMediaSources( + int index, List mediaSources, ShuffleOrder shuffleOrder) { + handler + .obtainMessage( + MSG_ADD_MEDIA_SOURCES, + index, + /* ignored */ 0, + new PlaylistUpdateMessage( + mediaSources, + shuffleOrder, + /* windowIndex= */ C.INDEX_UNSET, + /* positionUs= */ C.TIME_UNSET)) + .sendToTarget(); + } + + public void removeMediaSources(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { + handler + .obtainMessage(MSG_REMOVE_MEDIA_SOURCES, fromIndex, toIndex, shuffleOrder) + .sendToTarget(); + } + + public void moveMediaSources( + int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) { + MoveMediaItemsMessage moveMediaItemsMessage = + new MoveMediaItemsMessage(fromIndex, toIndex, newFromIndex, shuffleOrder); + handler.obtainMessage(MSG_MOVE_MEDIA_SOURCES, moveMediaItemsMessage).sendToTarget(); + } + + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + handler.obtainMessage(MSG_SET_SHUFFLE_ORDER, shuffleOrder).sendToTarget(); + } + @Override public synchronized void sendMessage(PlayerMessage message) { if (released || !internalPlaybackThread.isAlive()) { @@ -275,13 +327,11 @@ public Looper getPlaybackLooper() { return internalPlaybackThread.getLooper(); } - // MediaSource.MediaSourceCaller implementation. + // Playlist.PlaylistInfoRefreshListener implementation. @Override - public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { - handler - .obtainMessage(MSG_REFRESH_SOURCE_INFO, new MediaSourceRefreshInfo(source, timeline)) - .sendToTarget(); + public void onPlaylistUpdateRequested() { + handler.sendEmptyMessage(MSG_PLAYLIST_UPDATE_REQUESTED); } // MediaPeriod.Callback implementation. @@ -313,14 +363,12 @@ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { // Handler.Callback implementation. @Override + @SuppressWarnings("unchecked") public boolean handleMessage(Message msg) { try { switch (msg.what) { case MSG_PREPARE: - prepareInternal( - (MediaSource) msg.obj, - /* resetPosition= */ msg.arg1 != 0, - /* resetState= */ msg.arg2 != 0); + prepareInternal(); break; case MSG_SET_PLAY_WHEN_READY: setPlayWhenReadyInternal(msg.arg1 != 0); @@ -356,9 +404,6 @@ public boolean handleMessage(Message msg) { case MSG_PERIOD_PREPARED: handlePeriodPrepared((MediaPeriod) msg.obj); break; - case MSG_REFRESH_SOURCE_INFO: - handleSourceInfoRefreshed((MediaSourceRefreshInfo) msg.obj); - break; case MSG_SOURCE_CONTINUE_LOADING_REQUESTED: handleContinueLoadingRequested((MediaPeriod) msg.obj); break; @@ -375,6 +420,24 @@ public boolean handleMessage(Message msg) { case MSG_SEND_MESSAGE_TO_TARGET_THREAD: sendMessageToTargetThread((PlayerMessage) msg.obj); break; + case MSG_SET_MEDIA_SOURCES: + setMediaItemsInternal((PlaylistUpdateMessage) msg.obj); + break; + case MSG_ADD_MEDIA_SOURCES: + addMediaItemsInternal((PlaylistUpdateMessage) msg.obj, msg.arg1); + break; + case MSG_MOVE_MEDIA_SOURCES: + moveMediaItemsInternal((MoveMediaItemsMessage) msg.obj); + break; + case MSG_REMOVE_MEDIA_SOURCES: + removeMediaItemsInternal(msg.arg1, msg.arg2, (ShuffleOrder) msg.obj); + break; + case MSG_SET_SHUFFLE_ORDER: + setShuffleOrderInternal((ShuffleOrder) msg.obj); + break; + case MSG_PLAYLIST_UPDATE_REQUESTED: + playlistUpdateRequestedInternal(); + break; case MSG_RELEASE: releaseInternal(); // Return immediately to not send playback info updates after release. @@ -509,21 +572,77 @@ private void maybeNotifyPlaybackInfoChanged() { } } - private void prepareInternal(MediaSource mediaSource, boolean resetPosition, boolean resetState) { - pendingPrepareCount++; + private void prepareInternal() { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ true, - resetPosition, - resetState, + /* resetPosition= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); loadControl.onPrepared(); - this.mediaSource = mediaSource; - setState(Player.STATE_BUFFERING); - mediaSource.prepareSource(/* caller= */ this, bandwidthMeter.getTransferListener()); + setState(playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); + playlist.prepare(bandwidthMeter.getTransferListener()); handler.sendEmptyMessage(MSG_DO_SOME_WORK); } + private void setMediaItemsInternal(PlaylistUpdateMessage playlistUpdateMessage) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + if (playlistUpdateMessage.windowIndex != C.INDEX_UNSET) { + pendingInitialSeekPosition = + new SeekPosition( + new Playlist.PlaylistTimeline( + playlistUpdateMessage.mediaSourceHolders, playlistUpdateMessage.shuffleOrder), + playlistUpdateMessage.windowIndex, + playlistUpdateMessage.positionUs); + } + Timeline timeline = + playlist.setMediaSources( + playlistUpdateMessage.mediaSourceHolders, playlistUpdateMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void addMediaItemsInternal(PlaylistUpdateMessage addMessage, int insertionIndex) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = + playlist.addMediaSources( + insertionIndex == C.INDEX_UNSET ? playlist.getSize() : insertionIndex, + addMessage.mediaSourceHolders, + addMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void moveMediaItemsInternal(MoveMediaItemsMessage moveMediaItemsMessage) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = + playlist.moveMediaSourceRange( + moveMediaItemsMessage.fromIndex, + moveMediaItemsMessage.toIndex, + moveMediaItemsMessage.newFromIndex, + moveMediaItemsMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void removeMediaItemsInternal(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = playlist.removeMediaSourceRange(fromIndex, toIndex, shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void playlistUpdateRequestedInternal() throws ExoPlaybackException { + handlePlaylistInfoRefreshed(playlist.createTimeline()); + } + + private void setShuffleOrderInternal(ShuffleOrder shuffleOrder) throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = playlist.setShuffleOrder(shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + private void setPlayWhenReadyInternal(boolean playWhenReady) throws ExoPlaybackException { rebuffering = false; this.playWhenReady = playWhenReady; @@ -563,7 +682,11 @@ private void seekToCurrentPosition(boolean sendDiscontinuity) throws ExoPlayback // position of the playing period to make sure none of the removed period is played. MediaPeriodId periodId = queue.getPlayingPeriod().info.id; long newPositionUs = - seekToPeriodPosition(periodId, playbackInfo.positionUs, /* forceDisableRenderers= */ true); + seekToPeriodPosition( + periodId, + playbackInfo.positionUs, + /* forceDisableRenderers= */ true, + /* forceBufferingState= */ false); if (newPositionUs != playbackInfo.positionUs) { playbackInfo = copyWithNewPosition(periodId, newPositionUs, playbackInfo.contentPositionUs); if (sendDiscontinuity) { @@ -741,14 +864,15 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti long periodPositionUs; long contentPositionUs; boolean seekPositionAdjusted; + @Nullable Pair resolvedSeekPosition = resolveSeekPosition(seekPosition, /* trySubsequentPeriods= */ true); if (resolvedSeekPosition == null) { // The seek position was valid for the timeline that it was performed into, but the // timeline has changed or is not ready and a suitable seek position could not be resolved. - periodId = playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period); - periodPositionUs = C.TIME_UNSET; + periodId = getDummyFirstMediaPeriodForAds(); contentPositionUs = C.TIME_UNSET; + periodPositionUs = C.TIME_UNSET; seekPositionAdjusted = true; } else { // Update the resolved seek position to take ads into account. @@ -765,7 +889,7 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti } try { - if (mediaSource == null || pendingPrepareCount > 0) { + if (playbackInfo.timeline.isEmpty() || !playlist.isPrepared()) { // Save seek position for later, as we are still waiting for a prepared source. pendingInitialSeekPosition = seekPosition; } else if (periodPositionUs == C.TIME_UNSET) { @@ -773,9 +897,9 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti setState(Player.STATE_ENDED); resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ false, /* resetPosition= */ true, - /* resetState= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); } else { // Execute the seek in the current media periods. @@ -795,7 +919,11 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti return; } } - newPeriodPositionUs = seekToPeriodPosition(periodId, newPeriodPositionUs); + newPeriodPositionUs = + seekToPeriodPosition( + periodId, + newPeriodPositionUs, + /* forceBufferingState= */ playbackInfo.playbackState == Player.STATE_ENDED); seekPositionAdjusted |= periodPositionUs != newPeriodPositionUs; periodPositionUs = newPeriodPositionUs; } @@ -807,19 +935,26 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti } } - private long seekToPeriodPosition(MediaPeriodId periodId, long periodPositionUs) + private long seekToPeriodPosition( + MediaPeriodId periodId, long periodPositionUs, boolean forceBufferingState) throws ExoPlaybackException { // Force disable renderers if they are reading from a period other than the one being played. return seekToPeriodPosition( - periodId, periodPositionUs, queue.getPlayingPeriod() != queue.getReadingPeriod()); + periodId, + periodPositionUs, + queue.getPlayingPeriod() != queue.getReadingPeriod(), + forceBufferingState); } private long seekToPeriodPosition( - MediaPeriodId periodId, long periodPositionUs, boolean forceDisableRenderers) + MediaPeriodId periodId, + long periodPositionUs, + boolean forceDisableRenderers, + boolean forceBufferingState) throws ExoPlaybackException { stopRenderers(); rebuffering = false; - if (playbackInfo.playbackState != Player.STATE_IDLE && !playbackInfo.timeline.isEmpty()) { + if (forceBufferingState || playbackInfo.playbackState == Player.STATE_READY) { setState(Player.STATE_BUFFERING); } @@ -920,13 +1055,11 @@ private void stopInternal( boolean forceResetRenderers, boolean resetPositionAndState, boolean acknowledgeStop) { resetInternal( /* resetRenderers= */ forceResetRenderers || !foregroundMode, - /* releaseMediaSource= */ true, /* resetPosition= */ resetPositionAndState, - /* resetState= */ resetPositionAndState, + /* releasePlaylist= */ true, + /* clearPlaylist= */ resetPositionAndState, /* resetError= */ resetPositionAndState); - playbackInfoUpdate.incrementPendingOperationAcks( - pendingPrepareCount + (acknowledgeStop ? 1 : 0)); - pendingPrepareCount = 0; + playbackInfoUpdate.incrementPendingOperationAcks(acknowledgeStop ? 1 : 0); loadControl.onStopped(); setState(Player.STATE_IDLE); } @@ -934,9 +1067,9 @@ private void stopInternal( private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* releaseMediaSource= */ true, /* resetPosition= */ true, - /* resetState= */ true, + /* releasePlaylist= */ true, + /* clearPlaylist= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -949,9 +1082,9 @@ private void releaseInternal() { private void resetInternal( boolean resetRenderers, - boolean releaseMediaSource, boolean resetPosition, - boolean resetState, + boolean releasePlaylist, + boolean clearPlaylist, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; @@ -979,8 +1112,8 @@ private void resetInternal( if (resetPosition) { pendingInitialSeekPosition = null; - } else if (resetState) { - // When resetting the state, also reset the period-based PlaybackInfo position and convert + } else if (clearPlaylist) { + // When clearing the playlist, also reset the period-based PlaybackInfo position and convert // existing position to initial seek instead. resetPosition = true; if (pendingInitialSeekPosition == null && !playbackInfo.timeline.isEmpty()) { @@ -991,51 +1124,65 @@ private void resetInternal( } } - queue.clear(/* keepFrontPeriodUid= */ !resetState); + queue.clear(/* keepFrontPeriodUid= */ !clearPlaylist); shouldContinueLoading = false; - if (resetState) { - queue.setTimeline(Timeline.EMPTY); + Timeline timeline = playbackInfo.timeline; + if (clearPlaylist) { + timeline = playlist.clear(/* shuffleOrder= */ null); + queue.setTimeline(timeline); for (PendingMessageInfo pendingMessageInfo : pendingMessages) { pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); } pendingMessages.clear(); nextPendingMessageIndex = 0; } - MediaPeriodId mediaPeriodId = - resetPosition - ? playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period) - : playbackInfo.periodId; + MediaPeriodId mediaPeriodId = playbackInfo.periodId; + long contentPositionUs = playbackInfo.contentPositionUs; + if (resetPosition) { + mediaPeriodId = + timeline.isEmpty() + ? playbackInfo.getDummyPeriodForEmptyTimeline() + : getDummyFirstMediaPeriodForAds(); + contentPositionUs = C.TIME_UNSET; + } // Set the start position to TIME_UNSET so that a subsequent seek to 0 isn't ignored. long startPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.positionUs; - long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; playbackInfo = new PlaybackInfo( - resetState ? Timeline.EMPTY : playbackInfo.timeline, + timeline, mediaPeriodId, startPositionUs, contentPositionUs, playbackInfo.playbackState, resetError ? null : playbackInfo.playbackError, /* isLoading= */ false, - resetState ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, - resetState ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + clearPlaylist ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + clearPlaylist ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, mediaPeriodId, startPositionUs, /* totalBufferedDurationUs= */ 0, startPositionUs); - if (releaseMediaSource) { - if (mediaSource != null) { - mediaSource.releaseSource(/* caller= */ this); - mediaSource = null; - } + if (releasePlaylist) { + playlist.release(); } } + private MediaPeriodId getDummyFirstMediaPeriodForAds() { + MediaPeriodId dummyFirstMediaPeriodId = + playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period); + if (!playbackInfo.timeline.isEmpty()) { + // add ad metadata if any and propagate the window sequence number to new period id. + dummyFirstMediaPeriodId = + queue.resolveMediaPeriodIdForAds(dummyFirstMediaPeriodId.periodUid, /* positionUs= */ 0); + } + return dummyFirstMediaPeriodId; + } + private void sendMessageInternal(PlayerMessage message) throws ExoPlaybackException { if (message.getPositionMs() == C.TIME_UNSET) { // If no delivery time is specified, trigger immediate message delivery. sendMessageToTarget(message); - } else if (mediaSource == null || pendingPrepareCount > 0) { + } else if (playbackInfo.timeline.isEmpty()) { // Still waiting for initial timeline to resolve position. pendingMessages.add(new PendingMessageInfo(message)); } else { @@ -1355,86 +1502,109 @@ private void maybeThrowSourceInfoRefreshError() throws IOException { } } } - mediaSource.maybeThrowSourceInfoRefreshError(); + playlist.maybeThrowSourceInfoRefreshError(); } - private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) - throws ExoPlaybackException { - if (sourceRefreshInfo.source != mediaSource) { - // Stale event. - return; - } - playbackInfoUpdate.incrementPendingOperationAcks(pendingPrepareCount); - pendingPrepareCount = 0; - + private void handlePlaylistInfoRefreshed(Timeline timeline) throws ExoPlaybackException { Timeline oldTimeline = playbackInfo.timeline; - Timeline timeline = sourceRefreshInfo.timeline; queue.setTimeline(timeline); playbackInfo = playbackInfo.copyWithTimeline(timeline); resolvePendingMessagePositions(); - - MediaPeriodId newPeriodId = playbackInfo.periodId; + if (timeline.isEmpty()) { + @Nullable SeekPosition pendingInitialSeekPosition = this.pendingInitialSeekPosition; + handleEndOfPlaylist(); + // Retain seek position if any. + this.pendingInitialSeekPosition = pendingInitialSeekPosition; + return; + } + MediaPeriodId oldPeriodId = playbackInfo.periodId; + Object newPeriodUid = oldPeriodId.periodUid; long oldContentPositionUs = - playbackInfo.periodId.isAd() ? playbackInfo.contentPositionUs : playbackInfo.positionUs; + oldPeriodId.isAd() ? playbackInfo.contentPositionUs : playbackInfo.positionUs; long newContentPositionUs = oldContentPositionUs; + boolean forceBufferingState = false; if (pendingInitialSeekPosition != null) { // Resolve initial seek position. + @Nullable Pair periodPosition = resolveSeekPosition(pendingInitialSeekPosition, /* trySubsequentPeriods= */ true); - pendingInitialSeekPosition = null; if (periodPosition == null) { - // The seek position was valid for the timeline that it was performed into, but the - // timeline has changed and a suitable seek position could not be resolved in the new one. - handleSourceInfoRefreshEndedPlayback(); - return; + // The initial seek in the empty old timeline is invalid in the new timeline. + handleEndOfPlaylist(); + // Use the period resulting from the reset. + newPeriodUid = playbackInfo.periodId.periodUid; + newContentPositionUs = C.TIME_UNSET; + } else { + // The pending seek has been resolved successfully in the new timeline. + newPeriodUid = periodPosition.first; + newContentPositionUs = + pendingInitialSeekPosition.windowPositionUs == C.TIME_UNSET + ? C.TIME_UNSET + : periodPosition.second; + forceBufferingState = playbackInfo.playbackState == Player.STATE_ENDED; } - newContentPositionUs = periodPosition.second; - newPeriodId = queue.resolveMediaPeriodIdForAds(periodPosition.first, newContentPositionUs); - } else if (oldContentPositionUs == C.TIME_UNSET && !timeline.isEmpty()) { - // Resolve unset start position to default position. + pendingInitialSeekPosition = null; + } else if (oldTimeline.isEmpty()) { + // Resolve to default position if the old timeline is empty and no seek is requested above. Pair defaultPosition = getPeriodPosition( - timeline, timeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET); - newPeriodId = queue.resolveMediaPeriodIdForAds(defaultPosition.first, defaultPosition.second); - if (!newPeriodId.isAd()) { - // Keep unset start position if we need to play an ad first. - newContentPositionUs = defaultPosition.second; - } - } else if (timeline.getIndexOfPeriod(newPeriodId.periodUid) == C.INDEX_UNSET) { + timeline, + timeline.getFirstWindowIndex(shuffleModeEnabled), + /* windowPositionUs= */ C.TIME_UNSET); + newPeriodUid = defaultPosition.first; + newContentPositionUs = C.TIME_UNSET; + } else if (timeline.getIndexOfPeriod(newPeriodUid) == C.INDEX_UNSET) { // The current period isn't in the new timeline. Attempt to resolve a subsequent period whose // window we can restart from. - Object newPeriodUid = resolveSubsequentPeriod(newPeriodId.periodUid, oldTimeline, timeline); - if (newPeriodUid == null) { - // We failed to resolve a suitable restart position. - handleSourceInfoRefreshEndedPlayback(); - return; - } - // We resolved a subsequent period. Start at the default position in the corresponding window. - Pair defaultPosition = - getPeriodPosition( - timeline, timeline.getPeriodByUid(newPeriodUid, period).windowIndex, C.TIME_UNSET); - newContentPositionUs = defaultPosition.second; - newPeriodId = queue.resolveMediaPeriodIdForAds(defaultPosition.first, newContentPositionUs); - } else { - // Recheck if the current ad still needs to be played or if we need to start playing an ad. - newPeriodId = - queue.resolveMediaPeriodIdForAds(playbackInfo.periodId.periodUid, newContentPositionUs); - if (!playbackInfo.periodId.isAd() && !newPeriodId.isAd()) { - // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and - // only MediaPeriodId.nextAdGroupIndex may have changed. This postpones a potential - // discontinuity until we reach the former next ad group position. - newPeriodId = playbackInfo.periodId; - } - } - - if (playbackInfo.periodId.equals(newPeriodId) && oldContentPositionUs == newContentPositionUs) { + @Nullable + Object subsequentPeriodUid = + resolveSubsequentPeriod( + window, period, repeatMode, shuffleModeEnabled, newPeriodUid, oldTimeline, timeline); + if (subsequentPeriodUid == null) { + // We failed to resolve a suitable restart position but the timeline is not empty. + handleEndOfPlaylist(); + // Use period and position resulting from the reset. + newPeriodUid = playbackInfo.periodId.periodUid; + newContentPositionUs = C.TIME_UNSET; + } else { + // We resolved a subsequent period. Start at the default position in the corresponding + // window. + Pair defaultPosition = + getPeriodPosition( + timeline, + timeline.getPeriodByUid(subsequentPeriodUid, period).windowIndex, + C.TIME_UNSET); + newPeriodUid = defaultPosition.first; + newContentPositionUs = C.TIME_UNSET; + } + } + + // Ensure ad insertion metadata is up to date. + long contentPositionForAdResolution = newContentPositionUs; + if (contentPositionForAdResolution == C.TIME_UNSET) { + contentPositionForAdResolution = + timeline.getWindow(timeline.getPeriodByUid(newPeriodUid, period).windowIndex, window) + .defaultPositionUs; + } + MediaPeriodId periodIdWithAds = + queue.resolveMediaPeriodIdForAds(newPeriodUid, contentPositionForAdResolution); + boolean oldAndNewPeriodIdAreSame = + oldPeriodId.periodUid.equals(newPeriodUid) + && !oldPeriodId.isAd() + && !periodIdWithAds.isAd(); + // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and + // only MediaPeriodId.nextAdGroupIndex may have changed. This postpones a potential + // discontinuity until we reach the former next ad group position. + MediaPeriodId newPeriodId = oldAndNewPeriodIdAreSame ? oldPeriodId : periodIdWithAds; + + if (oldPeriodId.equals(newPeriodId) && oldContentPositionUs == newContentPositionUs) { // We can keep the current playing period. Update the rest of the queued periods. if (!queue.updateQueuedPeriods(rendererPositionUs, getMaxRendererReadPositionUs())) { seekToCurrentPosition(/* sendDiscontinuity= */ false); } } else { // Something changed. Seek to new start position. - MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); + @Nullable MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); if (periodHolder != null) { // Update the new playing media period info if it already exists. while (periodHolder.getNext() != null) { @@ -1444,9 +1614,16 @@ private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) } } } - // Actually do the seek. long newPositionUs = newPeriodId.isAd() ? 0 : newContentPositionUs; - long seekedToPositionUs = seekToPeriodPosition(newPeriodId, newPositionUs); + if (!newPeriodId.isAd() && newContentPositionUs == C.TIME_UNSET) { + // Get the default position for the first new period that is not an ad. + int windowIndex = timeline.getPeriodByUid(newPeriodId.periodUid, period).windowIndex; + newContentPositionUs = timeline.getWindow(windowIndex, window).getDefaultPositionUs(); + newPositionUs = newContentPositionUs; + } + // Actually do the seek. + long seekedToPositionUs = + seekToPeriodPosition(newPeriodId, newPositionUs, forceBufferingState); playbackInfo = copyWithNewPosition(newPeriodId, seekedToPositionUs, newContentPositionUs); } handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); @@ -1477,47 +1654,19 @@ private long getMaxRendererReadPositionUs() { return maxReadPositionUs; } - private void handleSourceInfoRefreshEndedPlayback() { + private void handleEndOfPlaylist() { if (playbackInfo.playbackState != Player.STATE_IDLE) { setState(Player.STATE_ENDED); } - // Reset, but retain the source so that it can still be used should a seek occur. + // Reset, but retain the playlist so that it can still resume after a seek or be modified. resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ false, /* resetPosition= */ true, - /* resetState= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); } - /** - * Given a period index into an old timeline, finds the first subsequent period that also exists - * in a new timeline. The uid of this period in the new timeline is returned. - * - * @param oldPeriodUid The index of the period in the old timeline. - * @param oldTimeline The old timeline. - * @param newTimeline The new timeline. - * @return The uid in the new timeline of the first subsequent period, or null if no such period - * was found. - */ - private @Nullable Object resolveSubsequentPeriod( - Object oldPeriodUid, Timeline oldTimeline, Timeline newTimeline) { - int oldPeriodIndex = oldTimeline.getIndexOfPeriod(oldPeriodUid); - int newPeriodIndex = C.INDEX_UNSET; - int maxIterations = oldTimeline.getPeriodCount(); - for (int i = 0; i < maxIterations && newPeriodIndex == C.INDEX_UNSET; i++) { - oldPeriodIndex = - oldTimeline.getNextPeriodIndex( - oldPeriodIndex, period, window, repeatMode, shuffleModeEnabled); - if (oldPeriodIndex == C.INDEX_UNSET) { - // We've reached the end of the old timeline. - break; - } - newPeriodIndex = newTimeline.getIndexOfPeriod(oldTimeline.getUidOfPeriod(oldPeriodIndex)); - } - return newPeriodIndex == C.INDEX_UNSET ? null : newTimeline.getUidOfPeriod(newPeriodIndex); - } - /** * Converts a {@link SeekPosition} into the corresponding (periodUid, periodPositionUs) for the * internal timeline. @@ -1566,7 +1715,15 @@ private Pair resolveSeekPosition( if (trySubsequentPeriods) { // Try and find a subsequent period from the seek timeline in the internal timeline. @Nullable - Object periodUid = resolveSubsequentPeriod(periodPosition.first, seekTimeline, timeline); + Object periodUid = + resolveSubsequentPeriod( + window, + period, + repeatMode, + shuffleModeEnabled, + periodPosition.first, + seekTimeline, + timeline); if (periodUid != null) { // We found one. Use the default position of the corresponding window. return getPeriodPosition( @@ -1587,13 +1744,9 @@ private Pair getPeriodPosition( } private void updatePeriods() throws ExoPlaybackException, IOException { - if (mediaSource == null) { - // The player has no media source yet. - return; - } - if (pendingPrepareCount > 0) { + if (playbackInfo.timeline.isEmpty() || !playlist.isPrepared()) { // We're waiting to get information about periods. - mediaSource.maybeThrowSourceInfoRefreshError(); + playlist.maybeThrowSourceInfoRefreshError(); return; } maybeUpdateLoadingPeriod(); @@ -1613,7 +1766,7 @@ private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException rendererCapabilities, trackSelector, loadControl.getAllocator(), - mediaSource, + playlist, info, emptyTrackSelectorResult); mediaPeriodHolder.mediaPeriod.prepare(this, info.startPositionUs); @@ -1632,7 +1785,7 @@ private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException } private void maybeUpdateReadingPeriod() throws ExoPlaybackException { - MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + @Nullable MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); if (readingPeriodHolder == null) { return; } @@ -2008,6 +2161,44 @@ private void sendPlaybackParametersChangedInternal( .sendToTarget(); } + /** + * Given a period index into an old timeline, finds the first subsequent period that also exists + * in a new timeline. The uid of this period in the new timeline is returned. + * + * @param window A {@link Timeline.Window} to be used internally. + * @param period A {@link Timeline.Period} to be used internally. + * @param repeatMode The repeat mode to use. + * @param shuffleModeEnabled Whether the shuffle mode is enabled. + * @param oldPeriodUid The index of the period in the old timeline. + * @param oldTimeline The old timeline. + * @param newTimeline The new timeline. + * @return The uid in the new timeline of the first subsequent period, or null if no such period + * was found. + */ + /* package */ static @Nullable Object resolveSubsequentPeriod( + Timeline.Window window, + Timeline.Period period, + @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled, + Object oldPeriodUid, + Timeline oldTimeline, + Timeline newTimeline) { + int oldPeriodIndex = oldTimeline.getIndexOfPeriod(oldPeriodUid); + int newPeriodIndex = C.INDEX_UNSET; + int maxIterations = oldTimeline.getPeriodCount(); + for (int i = 0; i < maxIterations && newPeriodIndex == C.INDEX_UNSET; i++) { + oldPeriodIndex = + oldTimeline.getNextPeriodIndex( + oldPeriodIndex, period, window, repeatMode, shuffleModeEnabled); + if (oldPeriodIndex == C.INDEX_UNSET) { + // We've reached the end of the old timeline. + break; + } + newPeriodIndex = newTimeline.getIndexOfPeriod(oldTimeline.getUidOfPeriod(oldPeriodIndex)); + } + return newPeriodIndex == C.INDEX_UNSET ? null : newTimeline.getUidOfPeriod(newPeriodIndex); + } + private static Format[] getFormats(TrackSelection newSelection) { // Build an array of formats contained by the selection. int length = newSelection != null ? newSelection.length() : 0; @@ -2068,14 +2259,38 @@ public int compareTo(PendingMessageInfo other) { } } - private static final class MediaSourceRefreshInfo { + private static final class PlaylistUpdateMessage { - public final MediaSource source; - public final Timeline timeline; + private final List mediaSourceHolders; + private final ShuffleOrder shuffleOrder; + private final int windowIndex; + private final long positionUs; - public MediaSourceRefreshInfo(MediaSource source, Timeline timeline) { - this.source = source; - this.timeline = timeline; + private PlaylistUpdateMessage( + List mediaSourceHolders, + ShuffleOrder shuffleOrder, + int windowIndex, + long positionUs) { + this.mediaSourceHolders = mediaSourceHolders; + this.shuffleOrder = shuffleOrder; + this.windowIndex = windowIndex; + this.positionUs = positionUs; + } + } + + private static class MoveMediaItemsMessage { + + public final int fromIndex; + public final int toIndex; + public final int newFromIndex; + public final ShuffleOrder shuffleOrder; + + public MoveMediaItemsMessage( + int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) { + this.fromIndex = fromIndex; + this.toIndex = toIndex; + this.newFromIndex = newFromIndex; + this.shuffleOrder = shuffleOrder; } } @@ -2084,7 +2299,7 @@ private static final class PlaybackInfoUpdate { private PlaybackInfo lastPlaybackInfo; private int operationAcks; private boolean positionDiscontinuity; - private @DiscontinuityReason int discontinuityReason; + @DiscontinuityReason private int discontinuityReason; public boolean hasPendingUpdate(PlaybackInfo playbackInfo) { return playbackInfo != lastPlaybackInfo || operationAcks > 0 || positionDiscontinuity; @@ -2112,5 +2327,4 @@ public void setPositionDiscontinuity(@DiscontinuityReason int discontinuityReaso this.discontinuityReason = discontinuityReason; } } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index 850d2b7d108..5bbbcbea2a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -19,7 +19,6 @@ import com.google.android.exoplayer2.source.ClippingMediaPeriod; import com.google.android.exoplayer2.source.EmptySampleStream; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -56,7 +55,7 @@ private final boolean[] mayRetainStreamFlags; private final RendererCapabilities[] rendererCapabilities; private final TrackSelector trackSelector; - private final MediaSource mediaSource; + private final Playlist playlist; @Nullable private MediaPeriodHolder next; private TrackGroupArray trackGroups; @@ -70,7 +69,7 @@ * @param rendererPositionOffsetUs The renderer time of the start of the period, in microseconds. * @param trackSelector The track selector. * @param allocator The allocator. - * @param mediaSource The media source that produced the media period. + * @param playlist The playlist. * @param info Information used to identify this media period in its timeline period. * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each * renderer. @@ -80,13 +79,13 @@ public MediaPeriodHolder( long rendererPositionOffsetUs, TrackSelector trackSelector, Allocator allocator, - MediaSource mediaSource, + Playlist playlist, MediaPeriodInfo info, TrackSelectorResult emptyTrackSelectorResult) { this.rendererCapabilities = rendererCapabilities; this.rendererPositionOffsetUs = rendererPositionOffsetUs; this.trackSelector = trackSelector; - this.mediaSource = mediaSource; + this.playlist = playlist; this.uid = info.id.periodUid; this.info = info; this.trackGroups = TrackGroupArray.EMPTY; @@ -94,8 +93,7 @@ public MediaPeriodHolder( sampleStreams = new SampleStream[rendererCapabilities.length]; mayRetainStreamFlags = new boolean[rendererCapabilities.length]; mediaPeriod = - createMediaPeriod( - info.id, mediaSource, allocator, info.startPositionUs, info.endPositionUs); + createMediaPeriod(info.id, playlist, allocator, info.startPositionUs, info.endPositionUs); } /** @@ -305,7 +303,7 @@ public long applyTrackSelection( /** Releases the media period. No other method should be called after the release. */ public void release() { disableTrackSelectionsInResult(); - releaseMediaPeriod(info.endPositionUs, mediaSource, mediaPeriod); + releaseMediaPeriod(info.endPositionUs, playlist, mediaPeriod); } /** @@ -402,11 +400,11 @@ private boolean isLoadingMediaPeriod() { /** Returns a media period corresponding to the given {@code id}. */ private static MediaPeriod createMediaPeriod( MediaPeriodId id, - MediaSource mediaSource, + Playlist playlist, Allocator allocator, long startPositionUs, long endPositionUs) { - MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator, startPositionUs); + MediaPeriod mediaPeriod = playlist.createPeriod(id, allocator, startPositionUs); if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { mediaPeriod = new ClippingMediaPeriod( @@ -417,12 +415,12 @@ private static MediaPeriod createMediaPeriod( /** Releases the given {@code mediaPeriod}, logging and suppressing any errors. */ private static void releaseMediaPeriod( - long endPositionUs, MediaSource mediaSource, MediaPeriod mediaPeriod) { + long endPositionUs, Playlist playlist, MediaPeriod mediaPeriod) { try { if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { - mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); + playlist.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); } else { - mediaSource.releasePeriod(mediaPeriod); + playlist.releasePeriod(mediaPeriod); } } catch (RuntimeException e) { // There's nothing we can do. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index 901b7b4d94b..5b39db54aa3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -19,7 +19,6 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; @@ -134,7 +133,7 @@ public boolean shouldLoadNextMediaPeriod() { * @param rendererCapabilities The renderer capabilities. * @param trackSelector The track selector. * @param allocator The allocator. - * @param mediaSource The media source that produced the media period. + * @param playlist The playlist. * @param info Information used to identify this media period in its timeline period. * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each * renderer. @@ -143,7 +142,7 @@ public MediaPeriodHolder enqueueNextMediaPeriodHolder( RendererCapabilities[] rendererCapabilities, TrackSelector trackSelector, Allocator allocator, - MediaSource mediaSource, + Playlist playlist, MediaPeriodInfo info, TrackSelectorResult emptyTrackSelectorResult) { long rendererPositionOffsetUs = @@ -158,7 +157,7 @@ public MediaPeriodHolder enqueueNextMediaPeriodHolder( rendererPositionOffsetUs, trackSelector, allocator, - mediaSource, + playlist, info, emptyTrackSelectorResult); if (loading != null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java index 9d2a3b54590..1f678f9e2ad 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/PlaybackInfo.java @@ -162,7 +162,7 @@ public PlaybackInfo( public MediaPeriodId getDummyFirstMediaPeriodId( boolean shuffleModeEnabled, Timeline.Window window, Timeline.Period period) { if (timeline.isEmpty()) { - return DUMMY_MEDIA_PERIOD_ID; + return getDummyPeriodForEmptyTimeline(); } int firstWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); int firstPeriodIndex = timeline.getWindow(firstWindowIndex, window).firstPeriodIndex; @@ -178,6 +178,11 @@ public MediaPeriodId getDummyFirstMediaPeriodId( return new MediaPeriodId(timeline.getUidOfPeriod(firstPeriodIndex), windowSequenceNumber); } + /** Returns dummy period id for an empty timeline. */ + public MediaPeriodId getDummyPeriodForEmptyTimeline() { + return DUMMY_MEDIA_PERIOD_ID; + } + /** * Copies playback info with new playing position. * 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 d89cce60250..1f1c23a9804 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 @@ -381,7 +381,8 @@ default void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reas * {@link #onPositionDiscontinuity(int)}. * * @param timeline The latest timeline. Never null, but may be empty. - * @param manifest The latest manifest. May be null. + * @param manifest The latest manifest in case the timeline has a single window only. Always + * null if the timeline has more than a single window. * @param reason The {@link TimelineChangeReason} responsible for this timeline change. * @deprecated Use {@link #onTimelineChanged(Timeline, int)} instead. The manifest can be * accessed by using {@link #getCurrentManifest()} or {@code timeline.getWindow(windowIndex, @@ -619,25 +620,17 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { int DISCONTINUITY_REASON_INTERNAL = 4; /** - * Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PREPARED}, {@link - * #TIMELINE_CHANGE_REASON_RESET} or {@link #TIMELINE_CHANGE_REASON_DYNAMIC}. + * Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED} or {@link + * #TIMELINE_CHANGE_REASON_SOURCE_UPDATE}. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({ - TIMELINE_CHANGE_REASON_PREPARED, - TIMELINE_CHANGE_REASON_RESET, - TIMELINE_CHANGE_REASON_DYNAMIC - }) + @IntDef({TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, TIMELINE_CHANGE_REASON_SOURCE_UPDATE}) @interface TimelineChangeReason {} - /** Timeline and manifest changed as a result of a player initialization with new media. */ - int TIMELINE_CHANGE_REASON_PREPARED = 0; - /** Timeline and manifest changed as a result of a player reset. */ - int TIMELINE_CHANGE_REASON_RESET = 1; - /** - * Timeline or manifest changed as a result of an dynamic update introduced by the played media. - */ - int TIMELINE_CHANGE_REASON_DYNAMIC = 2; + /** Timeline changed as a result of a change of the playlist items or the order of the items. */ + int TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED = 0; + /** Timeline changed as a result of a dynamic update introduced by the played media. */ + int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; /** Returns the component of this player for audio output, or null if audio is not supported. */ @Nullable diff --git a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java index 3e73a0eb042..a3dd3018ed9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; @@ -163,7 +164,9 @@ public Builder(Context context, RenderersFactory renderersFactory) { * @param bandwidthMeter A {@link BandwidthMeter}. * @param looper A {@link Looper} that must be used for all calls to the player. * @param analyticsCollector An {@link AnalyticsCollector}. - * @param useLazyPreparation Whether media sources should be initialized lazily. + * @param useLazyPreparation Whether playlist items should be prepared lazily. If false, all + * initial preparation steps (e.g., manifest loads) happen immediately. If true, these + * initial preparations are triggered only when the player starts buffering the media. * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. */ public Builder( @@ -300,6 +303,7 @@ public SimpleExoPlayer build() { loadControl, bandwidthMeter, analyticsCollector, + useLazyPreparation, clock, looper); } @@ -342,7 +346,6 @@ public SimpleExoPlayer build() { private int audioSessionId; private AudioAttributes audioAttributes; private float audioVolume; - @Nullable private MediaSource mediaSource; private List currentCues; @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; @Nullable private CameraMotionListener cameraMotionListener; @@ -359,6 +362,9 @@ public SimpleExoPlayer build() { * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. * @param analyticsCollector A factory for creating the {@link AnalyticsCollector} that will * collect and forward all player events. + * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest + * loads and other initial preparation steps happen immediately. If true, these initial + * preparations are triggered only when the player starts buffering the media. * @param clock The {@link Clock} that will be used by the instance. Should always be {@link * Clock#DEFAULT}, unless the player is being used from a test. * @param looper The {@link Looper} which must be used for all calls to the player and which is @@ -372,6 +378,7 @@ protected SimpleExoPlayer( LoadControl loadControl, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this( @@ -382,26 +389,14 @@ protected SimpleExoPlayer( DrmSessionManager.getDummyDrmSessionManager(), bandwidthMeter, analyticsCollector, + useLazyPreparation, clock, looper); } /** - * @param context A {@link Context}. - * @param renderersFactory A factory for creating {@link Renderer}s to be used by the instance. - * @param trackSelector The {@link TrackSelector} that will be used by the instance. - * @param loadControl The {@link LoadControl} that will be used by the instance. - * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance - * will not be used for DRM protected playbacks. - * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. - * @param analyticsCollector The {@link AnalyticsCollector} that will collect and forward all - * player events. - * @param clock The {@link Clock} that will be used by the instance. Should always be {@link - * Clock#DEFAULT}, unless the player is being used from a test. - * @param looper The {@link Looper} which must be used for all calls to the player and which is - * used to call listeners on. * @deprecated Use {@link #SimpleExoPlayer(Context, RenderersFactory, TrackSelector, LoadControl, - * BandwidthMeter, AnalyticsCollector, Clock, Looper)} instead, and pass the {@link + * BandwidthMeter, AnalyticsCollector, boolean, Clock, Looper)} instead, and pass the {@link * DrmSessionManager} to the {@link MediaSource} factories. */ @Deprecated @@ -413,6 +408,7 @@ protected SimpleExoPlayer( @Nullable DrmSessionManager drmSessionManager, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this.bandwidthMeter = bandwidthMeter; @@ -443,7 +439,15 @@ protected SimpleExoPlayer( // Build the player and associated objects. player = - new ExoPlayerImpl(renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + new ExoPlayerImpl( + renderers, + trackSelector, + loadControl, + bandwidthMeter, + analyticsCollector, + useLazyPreparation, + clock, + looper); analyticsCollector.setPlayer(player); addListener(analyticsCollector); addListener(componentListener); @@ -1164,51 +1168,151 @@ public ExoPlaybackException getPlaybackError() { return player.getPlaybackError(); } + /** @deprecated Use {@link #prepare()} instead. */ + @Deprecated @Override - @SuppressWarnings("deprecation") public void retry() { verifyApplicationThread(); - if (mediaSource != null - && (getPlaybackError() != null || getPlaybackState() == Player.STATE_IDLE)) { - prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); - } + prepare(); } @Override + public void prepare() { + verifyApplicationThread(); + @AudioFocusManager.PlayerCommand + int playerCommand = audioFocusManager.handlePrepare(getPlayWhenReady()); + updatePlayWhenReady(getPlayWhenReady(), playerCommand); + player.prepare(); + } + + /** + * @deprecated Use {@link #setMediaSource(MediaSource)} and {@link ExoPlayer#prepare()} instead. + */ @Deprecated + @Override @SuppressWarnings("deprecation") public void prepare(MediaSource mediaSource) { prepare(mediaSource, /* resetPosition= */ true, /* resetState= */ true); } - @Override + /** + * @deprecated Use {@link #setMediaSource(MediaSource, boolean)} and {@link ExoPlayer#prepare()} + * instead. + */ @Deprecated + @Override public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { verifyApplicationThread(); - setMediaSource(mediaSource); - prepareInternal(resetPosition, resetState); + setMediaSources( + Collections.singletonList(mediaSource), + /* startWindowIndex= */ resetPosition ? 0 : C.INDEX_UNSET, + /* startPositionMs= */ C.TIME_UNSET); + prepare(); } @Override - public void prepare() { + public void setMediaSources(List mediaSources) { verifyApplicationThread(); - prepareInternal(/* resetPosition= */ false, /* resetState= */ true); + analyticsCollector.resetForNewPlaylist(); + player.setMediaSources(mediaSources); } @Override - public void setMediaSource(MediaSource mediaSource, long startPositionMs) { + public void setMediaSources(List mediaSources, boolean resetPosition) { verifyApplicationThread(); - setMediaSourceInternal(mediaSource); - player.setMediaSource(mediaSource, startPositionMs); + analyticsCollector.resetForNewPlaylist(); + player.setMediaSources(mediaSources, resetPosition); + } + + @Override + public void setMediaSources( + List mediaSources, int startWindowIndex, long startPositionMs) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaSources(mediaSources, startWindowIndex, startPositionMs); } @Override public void setMediaSource(MediaSource mediaSource) { verifyApplicationThread(); - setMediaSourceInternal(mediaSource); + analyticsCollector.resetForNewPlaylist(); player.setMediaSource(mediaSource); } + @Override + public void setMediaSource(MediaSource mediaSource, boolean resetPosition) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaSource(mediaSource, resetPosition); + } + + @Override + public void setMediaSource(MediaSource mediaSource, long startPositionMs) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaSource(mediaSource, startPositionMs); + } + + @Override + public void addMediaSource(MediaSource mediaSource) { + verifyApplicationThread(); + player.addMediaSource(mediaSource); + } + + @Override + public void addMediaSource(int index, MediaSource mediaSource) { + verifyApplicationThread(); + player.addMediaSource(index, mediaSource); + } + + @Override + public void addMediaSources(List mediaSources) { + verifyApplicationThread(); + player.addMediaSources(mediaSources); + } + + @Override + public void addMediaSources(int index, List mediaSources) { + verifyApplicationThread(); + player.addMediaSources(index, mediaSources); + } + + @Override + public void moveMediaItem(int currentIndex, int newIndex) { + verifyApplicationThread(); + player.moveMediaItem(currentIndex, newIndex); + } + + @Override + public void moveMediaItems(int fromIndex, int toIndex, int newIndex) { + verifyApplicationThread(); + player.moveMediaItems(fromIndex, toIndex, newIndex); + } + + @Override + public MediaSource removeMediaItem(int index) { + verifyApplicationThread(); + return player.removeMediaItem(index); + } + + @Override + public void removeMediaItems(int fromIndex, int toIndex) { + verifyApplicationThread(); + player.removeMediaItems(fromIndex, toIndex); + } + + @Override + public void clearMediaItems() { + verifyApplicationThread(); + player.clearMediaItems(); + } + + @Override + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + verifyApplicationThread(); + player.setShuffleOrder(shuffleOrder); + } + @Override public void setPlayWhenReady(boolean playWhenReady) { verifyApplicationThread(); @@ -1286,6 +1390,7 @@ public SeekParameters getSeekParameters() { @Override public void setForegroundMode(boolean foregroundMode) { + verifyApplicationThread(); player.setForegroundMode(foregroundMode); } @@ -1293,13 +1398,6 @@ public void setForegroundMode(boolean foregroundMode) { public void stop(boolean reset) { verifyApplicationThread(); player.stop(reset); - if (mediaSource != null) { - mediaSource.removeEventListener(analyticsCollector); - analyticsCollector.resetForNewMediaSource(); - if (reset) { - mediaSource = null; - } - } audioFocusManager.handleStop(); currentCues = Collections.emptyList(); } @@ -1318,10 +1416,6 @@ public void release() { } surface = null; } - if (mediaSource != null) { - mediaSource.removeEventListener(analyticsCollector); - mediaSource = null; - } if (isPriorityTaskManagerRegistered) { Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); isPriorityTaskManagerRegistered = false; @@ -1455,23 +1549,6 @@ public void setHandleWakeLock(boolean handleWakeLock) { // Internal methods. - private void prepareInternal(boolean resetPosition, boolean resetState) { - Assertions.checkNotNull(mediaSource); - @AudioFocusManager.PlayerCommand - int playerCommand = audioFocusManager.handlePrepare(getPlayWhenReady()); - updatePlayWhenReady(getPlayWhenReady(), playerCommand); - player.prepareInternal(resetPosition, resetState); - } - - private void setMediaSourceInternal(MediaSource mediaSource) { - if (this.mediaSource != null) { - this.mediaSource.removeEventListener(analyticsCollector); - analyticsCollector.resetForNewMediaSource(); - } - this.mediaSource = mediaSource; - this.mediaSource.addEventListener(eventHandler, analyticsCollector); - } - private void removeSurfaceCallbacks() { if (textureView != null) { if (textureView.getSurfaceTextureListener() != componentListener) { 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 efc16501926..2cb46e099b1 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 @@ -135,11 +135,8 @@ public final void notifySeekStarted() { } } - /** - * Resets the analytics collector for a new media source. Should be called before the player is - * prepared with a new media source. - */ - public final void resetForNewMediaSource() { + /** Resets the analytics collector for a new playlist. */ + public final void resetForNewPlaylist() { // Copying the list is needed because onMediaPeriodReleased will modify the list. List mediaPeriodInfos = new ArrayList<>(mediaPeriodQueueTracker.mediaPeriodInfoQueue); @@ -806,9 +803,13 @@ public void onSeekProcessed() { /** Updates the queue with a newly created media period. */ public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { - boolean isInTimeline = timeline.getIndexOfPeriod(mediaPeriodId.periodUid) != C.INDEX_UNSET; + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + boolean isInTimeline = periodIndex != C.INDEX_UNSET; MediaPeriodInfo mediaPeriodInfo = - new MediaPeriodInfo(mediaPeriodId, isInTimeline ? timeline : Timeline.EMPTY, windowIndex); + new MediaPeriodInfo( + mediaPeriodId, + isInTimeline ? timeline : Timeline.EMPTY, + isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); mediaPeriodInfoQueue.add(mediaPeriodInfo); mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); @@ -824,7 +825,7 @@ public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { public boolean onMediaPeriodReleased(MediaPeriodId mediaPeriodId) { MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.remove(mediaPeriodId); if (mediaPeriodInfo == null) { - // The media period has already been removed from the queue in resetForNewMediaSource(). + // The media period has already been removed from the queue in resetForNewPlaylist(). return false; } mediaPeriodInfoQueue.remove(mediaPeriodInfo); 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 3fa9e8804ed..6d5820d1f31 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 @@ -617,12 +617,10 @@ private static String getDiscontinuityReasonString(@Player.DiscontinuityReason i private static String getTimelineChangeReasonString(@Player.TimelineChangeReason int reason) { switch (reason) { - case Player.TIMELINE_CHANGE_REASON_PREPARED: - return "PREPARED"; - case Player.TIMELINE_CHANGE_REASON_RESET: - return "RESET"; - case Player.TIMELINE_CHANGE_REASON_DYNAMIC: - return "DYNAMIC"; + case Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE: + return "SOURCE_UPDATE"; + case Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED: + return "PLAYLIST_CHANGED"; default: return "?"; } 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 320854565da..0893e01ec0e 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 @@ -39,6 +39,7 @@ import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.LoopingMediaSource; +import com.google.android.exoplayer2.source.MaskingMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; @@ -68,6 +69,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import java.io.IOException; import java.util.ArrayList; @@ -99,10 +101,12 @@ public final class ExoPlayerTest { private static final int TIMEOUT_MS = 10000; private Context context; + private Timeline dummyTimeline; @Before public void setUp() { context = ApplicationProvider.getApplicationContext(); + dummyTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ 0); } /** @@ -112,6 +116,7 @@ public void setUp() { @Test public void testPlayEmptyTimeline() throws Exception { Timeline timeline = Timeline.EMPTY; + Timeline expectedMaskingTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); FakeRenderer renderer = new FakeRenderer(); ExoPlayerTestRunner testRunner = new Builder() @@ -121,7 +126,10 @@ public void testPlayEmptyTimeline() throws Exception { .start() .blockUntilEnded(TIMEOUT_MS); testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesEqual(timeline); + testRunner.assertTimelinesSame(expectedMaskingTimeline, Timeline.EMPTY); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.formatReadCount).isEqualTo(0); assertThat(renderer.sampleBufferReadCount).isEqualTo(0); assertThat(renderer.isEnded).isFalse(); @@ -142,8 +150,10 @@ public void testPlaySinglePeriodTimeline() throws Exception { .start() .blockUntilEnded(TIMEOUT_MS); testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertTrackGroupsEqual(new TrackGroupArray(new TrackGroup(Builder.VIDEO_FORMAT))); assertThat(renderer.formatReadCount).isEqualTo(1); assertThat(renderer.sampleBufferReadCount).isEqualTo(1); @@ -165,8 +175,10 @@ public void testPlayMultiPeriodTimeline() throws Exception { testRunner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.formatReadCount).isEqualTo(3); assertThat(renderer.sampleBufferReadCount).isEqualTo(3); assertThat(renderer.isEnded).isTrue(); @@ -189,8 +201,10 @@ public void testPlayShortDurationPeriods() throws Exception { Integer[] expectedReasons = new Integer[99]; Arrays.fill(expectedReasons, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); testRunner.assertPositionDiscontinuityReasonsEqual(expectedReasons); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.formatReadCount).isEqualTo(100); assertThat(renderer.sampleBufferReadCount).isEqualTo(100); assertThat(renderer.isEnded).isTrue(); @@ -262,17 +276,22 @@ public boolean isEnded() { testRunner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); + testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(audioRenderer.positionResetCount).isEqualTo(1); assertThat(videoRenderer.isEnded).isTrue(); assertThat(audioRenderer.isEnded).isTrue(); } @Test - public void testRepreparationGivesFreshSourceInfo() throws Exception { + public void testResettingMediaSourcesGivesFreshSourceInfo() throws Exception { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); - Object firstSourceManifest = new Object(); - Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, firstSourceManifest); + Timeline firstTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, 1000_000_000)); MediaSource firstSource = new FakeMediaSource(firstTimeline, Builder.VIDEO_FORMAT); final CountDownLatch queuedSourceInfoCountDownLatch = new CountDownLatch(1); final CountDownLatch completePreparationCountDownLatch = new CountDownLatch(1); @@ -285,8 +304,8 @@ public synchronized void prepareSourceInternal( @Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); // We've queued a source info refresh on the playback thread's event queue. Allow the - // test thread to prepare the player with the third source, and block this thread (the - // playback thread) until the test thread's call to prepare() has returned. + // test thread to set the third source to the playlist, and block this thread (the + // playback thread) until the test thread's call to setMediaSources() has returned. queuedSourceInfoCountDownLatch.countDown(); try { completePreparationCountDownLatch.await(); @@ -301,12 +320,13 @@ public synchronized void prepareSourceInternal( // Prepare the player with a source with the first manifest and a non-empty timeline. Prepare // the player again with a source and a new manifest, which will never be exposed. Allow the - // test thread to prepare the player with a third source, and block the playback thread until - // the test thread's call to prepare() has returned. + // test thread to set a third source, and block the playback thread until the test thread's call + // to setMediaSources() has returned. ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparation") - .waitForTimelineChanged(firstTimeline) - .prepareSource(secondSource) + new ActionSchedule.Builder("testResettingMediaSourcesGivesFreshSourceInfo") + .waitForTimelineChanged( + firstTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .setMediaSources(secondSource) .executeRunnable( () -> { try { @@ -315,26 +335,31 @@ public synchronized void prepareSourceInternal( // Ignore. } }) - .prepareSource(thirdSource) + .setMediaSources(thirdSource) .executeRunnable(completePreparationCountDownLatch::countDown) .build(); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(firstSource) + .setMediaSources(firstSource) .setRenderers(renderer) .setActionSchedule(actionSchedule) .build(context) .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); testRunner.assertNoPositionDiscontinuities(); - // The first source's preparation completed with a non-empty timeline. When the player was - // re-prepared with the second source, it immediately exposed an empty timeline, but the source - // info refresh from the second source was suppressed as we re-prepared with the third source. - testRunner.assertTimelinesEqual(firstTimeline, Timeline.EMPTY, thirdTimeline); + // The first source's preparation completed with a real timeline. When the second source was + // prepared, it immediately exposed a dummy timeline, but the source info refresh from the + // second source was suppressed as we replace it with the third source before the update + // arrives. + testRunner.assertTimelinesSame( + dummyTimeline, firstTimeline, dummyTimeline, dummyTimeline, thirdTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertTrackGroupsEqual(new TrackGroupArray(new TrackGroup(Builder.VIDEO_FORMAT))); assertThat(renderer.isEnded).isTrue(); } @@ -346,7 +371,8 @@ public void testRepeatModeChanges() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testRepeatMode") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .playUntilStartOfWindow(/* windowIndex= */ 1) .setRepeatMode(Player.REPEAT_MODE_ONE) .playUntilStartOfWindow(/* windowIndex= */ 1) @@ -381,8 +407,10 @@ public void testRepeatModeChanges() throws Exception { Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(new FakeMediaSource.InitialTimeline(timeline), timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.isEnded).isTrue(); } @@ -411,7 +439,7 @@ public void testShuffleModeEnabledChanges() throws Exception { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(renderer) .setActionSchedule(actionSchedule) .build(context) @@ -457,12 +485,13 @@ public void testAdGroupWithLoadErrorIsSkipped() throws Exception { .pause() .waitForPlaybackState(Player.STATE_READY) .executeRunnable(() -> fakeMediaSource.setNewSourceInfo(adErrorTimeline, null)) - .waitForTimelineChanged(adErrorTimeline) + .waitForTimelineChanged( + adErrorTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(fakeMediaSource) + .setMediaSources(fakeMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -557,26 +586,31 @@ public void onSeekProcessed() { } @Test - public void testSeekProcessedCalledWithIllegalSeekPosition() throws Exception { + public void testIllegalSeekPositionDoesThrow() throws Exception { + final IllegalSeekPositionException[] exception = new IllegalSeekPositionException[1]; ActionSchedule actionSchedule = - new ActionSchedule.Builder("testSeekProcessedCalledWithIllegalSeekPosition") + new ActionSchedule.Builder("testIllegalSeekPositionDoesThrow") .waitForPlaybackState(Player.STATE_BUFFERING) - // The illegal seek position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + player.seekTo(/* windowIndex= */ 100, /* positionMs= */ 0); + } catch (IllegalSeekPositionException e) { + exception[0] = e; + } + } + }) .waitForPlaybackState(Player.STATE_ENDED) .build(); - final boolean[] onSeekProcessedCalled = new boolean[1]; - EventListener listener = - new EventListener() { - @Override - public void onSeekProcessed() { - onSeekProcessedCalled[0] = true; - } - }; - ExoPlayerTestRunner testRunner = - new Builder().setActionSchedule(actionSchedule).setEventListener(listener).build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); - assertThat(onSeekProcessedCalled[0]).isTrue(); + new Builder() + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertThat(exception[0]).isNotNull(); } @Test @@ -620,7 +654,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -648,7 +682,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -674,7 +708,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -692,7 +726,7 @@ public void testAllActivatedTrackSelectionAreReleasedForSinglePeriod() throws Ex FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -721,7 +755,7 @@ public void testAllActivatedTrackSelectionAreReleasedForMultiPeriods() throws Ex FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -758,7 +792,7 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreRemad .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -797,7 +831,7 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreReuse .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -821,31 +855,35 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreReuse @Test public void testDynamicTimelineChangeReason() throws Exception { - Timeline timeline1 = new FakeTimeline(new TimelineWindowDefinition(false, false, 100000)); + Timeline timeline = new FakeTimeline(new TimelineWindowDefinition(false, false, 100000)); final Timeline timeline2 = new FakeTimeline(new TimelineWindowDefinition(false, false, 20000)); - final FakeMediaSource mediaSource = new FakeMediaSource(timeline1, Builder.VIDEO_FORMAT); + final FakeMediaSource mediaSource = new FakeMediaSource(timeline, Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("testDynamicTimelineChangeReason") .pause() - .waitForTimelineChanged(timeline1) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline2, null)) - .waitForTimelineChanged(timeline2) + .waitForTimelineChanged( + timeline2, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline1, timeline2); + testRunner.assertTimelinesSame(dummyTimeline, timeline, timeline2); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test - public void testRepreparationWithPositionResetAndShufflingUsesFirstPeriod() throws Exception { + public void testResetMediaSourcesWithPositionResetAndShufflingUsesFirstPeriod() throws Exception { Timeline fakeTimeline = new FakeTimeline( new TimelineWindowDefinition( @@ -863,22 +901,25 @@ public void testRepreparationWithPositionResetAndShufflingUsesFirstPeriod() thro new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT), new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparationWithShuffle") + new ActionSchedule.Builder( + "testResetMediaSourcesWithPositionResetAndShufflingUsesFirstPeriod") // Wait for first preparation and enable shuffling. Plays period 0. .pause() .waitForPlaybackState(Player.STATE_READY) .setShuffleModeEnabled(true) - // Reprepare with second media source (keeping state, but with position reset). + // Set the second media source (with position reset). // Plays period 1 and 0 because of the reversed fake shuffle order. - .prepareSource(secondMediaSource, /* resetPosition= */ true, /* resetState= */ false) + .setMediaSources(/* resetPosition= */ true, secondMediaSource) .play() + .waitForPositionDiscontinuity() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(firstMediaSource) + .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); testRunner.assertPlayedPeriodIndices(0, 1, 0); } @@ -923,7 +964,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( .executeRunnable(() -> fakeMediaPeriodHolder[0].setPreparationComplete()) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -956,8 +997,10 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isAtLeast(50L); } @@ -988,8 +1031,10 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isAtLeast(50L); } @@ -1020,9 +1065,11 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY); + testRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_RESET); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isEqualTo(0); } @@ -1068,15 +1115,29 @@ public void testStopWithResetReleasesMediaSource() throws Exception { } @Test - public void testRepreparationDoesNotResetAfterStopWithReset() throws Exception { + public void testSettingNewStartPositionPossibleAfterStopWithReset() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaSource secondSource = new FakeMediaSource(timeline, Builder.VIDEO_FORMAT); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); + MediaSource secondSource = new FakeMediaSource(secondTimeline, Builder.VIDEO_FORMAT); + AtomicInteger windowIndexAfterStop = new AtomicInteger(); + AtomicLong positionAfterStop = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparationAfterStop") + new ActionSchedule.Builder("testSettingNewStartPositionPossibleAfterStopWithReset") .waitForPlaybackState(Player.STATE_READY) .stop(/* reset= */ true) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(secondSource) + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) + .setMediaSources(secondSource) + .prepare() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + windowIndexAfterStop.set(player.getCurrentWindowIndex()); + positionAfterStop.set(player.getCurrentPosition()); + } + }) + .waitForPlaybackState(Player.STATE_READY) .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() @@ -1086,62 +1147,159 @@ public void testRepreparationDoesNotResetAfterStopWithReset() throws Exception { .build(context) .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, timeline); + testRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_IDLE, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED); + testRunner.assertTimelinesSame( + dummyTimeline, + timeline, + Timeline.EMPTY, + new FakeMediaSource.InitialTimeline(secondTimeline), + secondTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertNoPositionDiscontinuities(); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, // stop(true) + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(windowIndexAfterStop.get()).isEqualTo(1); + assertThat(positionAfterStop.get()).isAtLeast(1000L); + testRunner.assertPlayedPeriodIndices(0, 1); } @Test - public void testSeekBeforeRepreparationPossibleAfterStopWithReset() throws Exception { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); - MediaSource secondSource = new FakeMediaSource(secondTimeline, Builder.VIDEO_FORMAT); + public void testResetPlaylistWithPreviousPosition() throws Exception { + Object firstWindowId = new Object(); + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedMaskingTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); + Timeline secondTimeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedMaskingTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + MediaSource secondSource = new FakeMediaSource(secondTimeline); + AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testSeekAfterStopWithReset") + new ActionSchedule.Builder("testResetPlaylistWithPreviousPosition") + .pause() .waitForPlaybackState(Player.STATE_READY) - .stop(/* reset= */ true) - .waitForPlaybackState(Player.STATE_IDLE) - // If we were still using the first timeline, this would throw. - .seek(/* windowIndex= */ 1, /* positionMs= */ 0) - .prepareSource(secondSource, /* resetPosition= */ false, /* resetState= */ true) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setMediaSources(/* windowIndex= */ 0, /* positionMs= */ 2000, secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + positionAfterReprepare.set(player.getCurrentPosition()); + } + }) + .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() .setTimeline(timeline) .setActionSchedule(actionSchedule) - .setExpectedPlayerEndedCount(2) .build(context) .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + + testRunner.assertTimelinesSame( + firstExpectedMaskingTimeline, timeline, secondExpectedMaskingTimeline, secondTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); - testRunner.assertPlayedPeriodIndices(0, 1); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(positionAfterReprepare.get()).isAtLeast(2000L); + } + + @Test + public void testResetPlaylistStartsFromDefaultPosition() throws Exception { + Object firstWindowId = new Object(); + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); + Timeline secondTimeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + MediaSource secondSource = new FakeMediaSource(secondTimeline); + AtomicLong positionAfterReprepare = new AtomicLong(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testResetPlaylistStartsFromDefaultPosition") + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setMediaSources(/* resetPosition= */ true, secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + positionAfterReprepare.set(player.getCurrentPosition()); + } + }) + .play() + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + testRunner.assertTimelinesSame( + firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(positionAfterReprepare.get()).isEqualTo(0L); } @Test - public void testReprepareAndKeepPositionWithNewMediaSource() throws Exception { + public void testResetPlaylistWithoutResettingPositionStartsFromOldPosition() throws Exception { + Object firstWindowId = new Object(); Timeline timeline = new FakeTimeline( - new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ new Object())); + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( - new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ new Object())); + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testReprepareAndKeepPositionWithNewMediaSource") + new ActionSchedule.Builder("testResetPlaylistWithoutResettingPositionStartsFromOldPosition") .pause() .waitForPlaybackState(Player.STATE_READY) .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) - .prepareSource(secondSource, /* resetPosition= */ false, /* resetState= */ true) - .waitForTimelineChanged(secondTimeline) + .setMediaSources(secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable( new PlayerRunnable() { @Override @@ -1160,7 +1318,13 @@ public void run(SimpleExoPlayer player) { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + testRunner.assertTimelinesSame( + firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(positionAfterReprepare.get()).isAtLeast(2000L); } @@ -1182,8 +1346,10 @@ public void testStopDuringPreparationOverwritesPreparation() throws Exception { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(Timeline.EMPTY); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, Timeline.EMPTY); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); } @@ -1208,8 +1374,10 @@ public void testStopAndSeekAfterStopDoesNotResetTimeline() throws Exception { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); } @@ -1221,9 +1389,8 @@ public void testReprepareAfterPlaybackError() throws Exception { .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ true, /* resetState= */ false) - .waitForPlaybackState(Player.STATE_READY) + .prepare() + .waitForPlaybackState(Player.STATE_BUFFERING) .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() @@ -1236,9 +1403,10 @@ public void testReprepareAfterPlaybackError() throws Exception { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test @@ -1260,8 +1428,7 @@ public void run(SimpleExoPlayer player) { positionHolder[0] = player.getCurrentPosition(); } }) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ false, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @@ -1283,52 +1450,29 @@ public void run(SimpleExoPlayer player) { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); assertThat(positionHolder[0]).isEqualTo(50); assertThat(positionHolder[1]).isEqualTo(50); } - @Test - public void testInvalidSeekPositionAfterSourceInfoRefreshStillUpdatesTimeline() throws Exception { - final Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource = new FakeMediaSource(/* timeline= */ null); - ActionSchedule actionSchedule = - new ActionSchedule.Builder("testInvalidSeekPositionSourceInfoRefreshStillUpdatesTimeline") - .waitForPlaybackState(Player.STATE_BUFFERING) - // Seeking to an invalid position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) - .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) - .setActionSchedule(actionSchedule) - .build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); - - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); - } - @Test public void testInvalidSeekPositionAfterSourceInfoRefreshWithShuffleModeEnabledUsesCorrectFirstPeriod() throws Exception { - FakeMediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource( - /* isAtomic= */ false, new FakeShuffleOrder(0), mediaSource, mediaSource); + FakeMediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2)); AtomicInteger windowIndexAfterUpdate = new AtomicInteger(); ActionSchedule actionSchedule = new ActionSchedule.Builder("testInvalidSeekPositionSourceInfoRefreshUsesCorrectFirstPeriod") + .setShuffleOrder(new FakeShuffleOrder(/* length= */ 0)) .setShuffleModeEnabled(true) .waitForPlaybackState(Player.STATE_BUFFERING) // Seeking to an invalid position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) + .seek( + /* windowIndex= */ 100, /* positionMs= */ 0, /* catchIllegalSeekException= */ true) .waitForPlaybackState(Player.STATE_ENDED) .executeRunnable( new PlayerRunnable() { @@ -1338,12 +1482,13 @@ public void run(SimpleExoPlayer player) { } }) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder() - .setMediaSource(concatenatingMediaSource) - .setActionSchedule(actionSchedule) - .build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + new ExoPlayerTestRunner.Builder() + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); assertThat(windowIndexAfterUpdate.get()).isEqualTo(1); } @@ -1357,7 +1502,8 @@ public void testRestartAfterEmptyTimelineWithShuffleModeEnabledUsesCorrectFirstP new ConcatenatingMediaSource(/* isAtomic= */ false, new FakeShuffleOrder(0)); AtomicInteger windowIndexAfterAddingSources = new AtomicInteger(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRestartAfterEmptyTimelineUsesCorrectFirstPeriod") + new ActionSchedule.Builder( + "testRestartAfterEmptyTimelineWithShuffleModeEnabledUsesCorrectFirstPeriod") .setShuffleModeEnabled(true) // Preparing with an empty media source will transition to ended state. .waitForPlaybackState(Player.STATE_ENDED) @@ -1377,7 +1523,7 @@ public void run(SimpleExoPlayer player) { }) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1391,7 +1537,7 @@ public void testPlaybackErrorAndReprepareDoesNotResetPosition() throws Exception final Timeline timeline = new FakeTimeline(/* windowCount= */ 2); final long[] positionHolder = new long[3]; final int[] windowIndexHolder = new int[3]; - final FakeMediaSource secondMediaSource = new FakeMediaSource(/* timeline= */ null); + final FakeMediaSource firstMediaSource = new FakeMediaSource(timeline); ActionSchedule actionSchedule = new ActionSchedule.Builder("testPlaybackErrorDoesNotResetPosition") .pause() @@ -1408,8 +1554,7 @@ public void run(SimpleExoPlayer player) { windowIndexHolder[0] = player.getCurrentWindowIndex(); } }) - .prepareSource(secondMediaSource, /* resetPosition= */ false, /* resetState= */ false) - .waitForPlaybackState(Player.STATE_BUFFERING) + .prepare() .executeRunnable( new PlayerRunnable() { @Override @@ -1417,7 +1562,6 @@ public void run(SimpleExoPlayer player) { // Position while repreparing. positionHolder[1] = player.getCurrentPosition(); windowIndexHolder[1] = player.getCurrentWindowIndex(); - secondMediaSource.setNewSourceInfo(timeline, /* newManifest= */ null); } }) .waitForPlaybackState(Player.STATE_READY) @@ -1434,7 +1578,7 @@ public void run(SimpleExoPlayer player) { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setTimeline(timeline) + .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build(context); try { @@ -1451,6 +1595,70 @@ public void run(SimpleExoPlayer player) { assertThat(windowIndexHolder[2]).isEqualTo(1); } + @Test + public void testSeekAfterPlaybackError() throws Exception { + final Timeline timeline = new FakeTimeline(/* windowCount= */ 2); + final long[] positionHolder = {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}; + final int[] windowIndexHolder = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + final FakeMediaSource firstMediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekAfterPlaybackError") + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 500) + .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) + .waitForPlaybackState(Player.STATE_IDLE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Position while in error state + positionHolder[0] = player.getCurrentPosition(); + windowIndexHolder[0] = player.getCurrentWindowIndex(); + } + }) + .seek(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET) + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Position while in error state + positionHolder[1] = player.getCurrentPosition(); + windowIndexHolder[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Position after prepare. + positionHolder[2] = player.getCurrentPosition(); + windowIndexHolder[2] = player.getCurrentWindowIndex(); + } + }) + .play() + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder() + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context); + try { + testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + fail(); + } catch (ExoPlaybackException e) { + // Expected exception. + } + assertThat(positionHolder[0]).isAtLeast(500L); + assertThat(positionHolder[1]).isEqualTo(0L); + assertThat(positionHolder[2]).isEqualTo(0L); + assertThat(windowIndexHolder[0]).isEqualTo(1); + assertThat(windowIndexHolder[1]).isEqualTo(0); + assertThat(windowIndexHolder[2]).isEqualTo(0); + } + @Test public void playbackErrorAndReprepareWithPositionResetKeepsWindowSequenceNumber() throws Exception { @@ -1461,7 +1669,8 @@ public void playbackErrorAndReprepareWithPositionResetKeepsWindowSequenceNumber( .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(mediaSource, /* resetPosition= */ true, /* resetState= */ false) + .seek(0, C.TIME_UNSET) + .prepare() .waitForPlaybackState(Player.STATE_READY) .play() .build(); @@ -1478,7 +1687,7 @@ public void onPlayerStateChanged( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .setAnalyticsListener(listener) .build(context); @@ -1494,16 +1703,18 @@ public void onPlayerStateChanged( @Test public void testPlaybackErrorTwiceStillKeepsTimeline() throws Exception { final Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource2 = new FakeMediaSource(/* timeline= */ null); + final FakeMediaSource mediaSource2 = new FakeMediaSource(timeline); ActionSchedule actionSchedule = new ActionSchedule.Builder("testPlaybackErrorDoesNotResetPosition") .pause() .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(mediaSource2, /* resetPosition= */ false, /* resetState= */ false) + .setMediaSources(/* resetPosition= */ false, mediaSource2) + .prepare() .waitForPlaybackState(Player.STATE_BUFFERING) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .waitForPlaybackState(Player.STATE_IDLE) .build(); ExoPlayerTestRunner testRunner = @@ -1517,9 +1728,12 @@ public void testPlaybackErrorTwiceStillKeepsTimeline() throws Exception { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline, dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test @@ -1549,7 +1763,8 @@ public void testSendMessagesAfterPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* positionMs= */ 50) .play() .build(); @@ -1669,21 +1884,12 @@ public void testSendMessagesAtStartAndEndOfPeriod() throws Exception { long duration1Ms = timeline.getWindow(0, new Window()).getDurationMs(); long duration2Ms = timeline.getWindow(1, new Window()).getDurationMs(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testSendMessages") - .pause() - .waitForPlaybackState(Player.STATE_BUFFERING) + new ActionSchedule.Builder("testSendMessagesAtStartAndEndOfPeriod") .sendMessage(targetStartFirstPeriod, /* windowIndex= */ 0, /* positionMs= */ 0) .sendMessage(targetEndMiddlePeriod, /* windowIndex= */ 0, /* positionMs= */ duration1Ms) .sendMessage(targetStartMiddlePeriod, /* windowIndex= */ 1, /* positionMs= */ 0) .sendMessage(targetEndLastPeriod, /* windowIndex= */ 1, /* positionMs= */ duration2Ms) - .play() - // Add additional prepare at end and wait until it's processed to ensure that - // messages sent at end of playback are received before test ends. - .waitForPlaybackState(Player.STATE_ENDED) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ false, /* resetState= */ true) - .waitForPlaybackState(Player.STATE_BUFFERING) - .waitForPlaybackState(Player.STATE_ENDED) + .waitForMessage(targetEndLastPeriod) .build(); new Builder() .setTimeline(timeline) @@ -1729,7 +1935,8 @@ public void testSendMessagesSeekOnDeliveryTimeAfterPreparation() throws Exceptio new ActionSchedule.Builder("testSendMessages") .waitForPlaybackState(Player.STATE_BUFFERING) .sendMessage(target, /* positionMs= */ 50) - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* positionMs= */ 50) .build(); new Builder() @@ -1770,7 +1977,8 @@ public void testSendMessagesSeekAfterDeliveryTimeAfterPreparation() throws Excep new ActionSchedule.Builder("testSendMessages") .pause() .sendMessage(target, /* positionMs= */ 50) - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* positionMs= */ 51) .play() .build(); @@ -1849,14 +2057,16 @@ public void testSendMessagesMoveCurrentWindowIndex() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* positionMs= */ 50) .executeRunnable(() -> mediaSource.setNewSourceInfo(secondTimeline, null)) - .waitForTimelineChanged(secondTimeline) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1872,7 +2082,7 @@ public void testSendMessagesMultiWindowDuringPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 2, /* positionMs= */ 50) .play() .build(); @@ -1893,7 +2103,8 @@ public void testSendMessagesMultiWindowAfterPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 2, /* positionMs= */ 50) .play() .build(); @@ -1922,15 +2133,17 @@ public void testSendMessagesMoveWindowIndex() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 1, /* positionMs= */ 50) .executeRunnable(() -> mediaSource.setNewSourceInfo(secondTimeline, null)) - .waitForTimelineChanged(secondTimeline) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* windowIndex= */ 0, /* positionMs= */ 0) .play() .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1964,7 +2177,7 @@ public void testSendMessagesNonLinearPeriodOrder() throws Exception { .play() .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2102,16 +2315,21 @@ public void testTimelineUpdateDropsPrebufferedPeriods() throws Exception { /* windowIndex= */ 0, /* positionMs= */ C.usToMs(TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US)) .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline2, /* newManifest= */ null)) - .waitForTimelineChanged(timeline2) + .waitForTimelineChanged( + timeline2, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPlayedPeriodIndices(0, 1); // Assert that the second period was re-created from the new timeline. assertThat(mediaSource.getCreatedMediaPeriods()).hasSize(3); @@ -2153,7 +2371,7 @@ public void testRepeatedSeeksToUnpreparedPeriodInSameWindowKeepsWindowSequenceNu .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2191,7 +2409,7 @@ protected void releaseSourceInternal() { ActionSchedule actionSchedule = new ActionSchedule.Builder("testInvalidSeekFallsBackToSubsequentPeriodOfTheRemovedPeriod") .pause() - .waitForTimelineChanged() + .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @Override @@ -2223,7 +2441,7 @@ public void run(SimpleExoPlayer player) { .play() .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2335,6 +2553,60 @@ public void run(SimpleExoPlayer player) { assertThat(eventListenerPlayWhenReady).containsExactly(true, true, true, false).inOrder(); } + @Test + public void testRecursiveTimelineChangeInStopAreReportedInCorrectOrder() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 2); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 3); + final AtomicReference playerReference = new AtomicReference<>(); + FakeMediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final EventListener eventListener = + new EventListener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int state) { + if (state == Player.STATE_IDLE) { + playerReference.get().setMediaSource(secondMediaSource); + } + } + }; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testRecursiveTimelineChangeInStopAreReportedInCorrectOrder") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + playerReference.set(player); + player.addListener(eventListener); + } + }) + // Ensure there are no further pending callbacks. + .delay(1) + .stop(/* reset= */ true) + .waitForPlaybackState(Player.STATE_IDLE) + .prepare() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setActionSchedule(actionSchedule) + .setTimeline(firstTimeline) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + exoPlayerTestRunner.assertTimelinesSame( + new FakeMediaSource.InitialTimeline(firstTimeline), + firstTimeline, + Timeline.EMPTY, + new FakeMediaSource.InitialTimeline(secondTimeline), + secondTimeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + @Test public void testClippedLoopedPeriodsArePlayedFully() throws Exception { long startPositionUs = 300_000; @@ -2387,7 +2659,7 @@ public void run(SimpleExoPlayer player) { .build(); new ExoPlayerTestRunner.Builder() .setClock(clock) - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2421,7 +2693,7 @@ public void testUpdateTrackSelectorThenSeekToUnpreparedPeriod_returnsEmptyTrackG List trackGroupsList = new ArrayList<>(); List trackSelectionsList = new ArrayList<>(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setSupportedFormats(Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT) .setActionSchedule(actionSchedule) .setEventListener( @@ -2469,7 +2741,7 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setRenderers(renderer) .build(context); try { @@ -2512,7 +2784,7 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .setRenderers(renderer) .build(context); @@ -2527,68 +2799,26 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { } @Test - public void failingDynamicUpdateOnlyThrowsWhenAvailablePeriodHasBeenFullyRead() throws Exception { - Timeline fakeTimeline = + public void removingLoopingLastPeriodFromPlaylistDoesNotThrow() throws Exception { + Timeline timeline = new FakeTimeline( new TimelineWindowDefinition( - /* isSeekable= */ true, - /* isDynamic= */ true, - /* durationUs= */ 10 * C.MICROS_PER_SECOND)); - AtomicReference wasReadyOnce = new AtomicReference<>(false); - MediaSource mediaSource = - new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT) { - @Override - public void maybeThrowSourceInfoRefreshError() throws IOException { - if (wasReadyOnce.get()) { - throw new IOException(); - } - } - }; + /* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 100_000)); + MediaSource mediaSource = new FakeMediaSource(timeline); + ConcatenatingMediaSource concatenatingMediaSource = new ConcatenatingMediaSource(mediaSource); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testFailingDynamicMediaSourceInTimelineOnlyThrowsLater") + new ActionSchedule.Builder("removingLoopingLastPeriodFromPlaylistDoesNotThrow") .pause() .waitForPlaybackState(Player.STATE_READY) - .executeRunnable(() -> wasReadyOnce.set(true)) - .play() - .build(); - FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); - ExoPlayerTestRunner testRunner = - new Builder() - .setMediaSource(mediaSource) - .setActionSchedule(actionSchedule) - .setRenderers(renderer) - .build(context); - try { - testRunner.start().blockUntilEnded(TIMEOUT_MS); - fail(); - } catch (ExoPlaybackException e) { - // Expected exception. - } - assertThat(renderer.sampleBufferReadCount).isAtLeast(1); - assertThat(renderer.hasReadStreamToEnd()).isTrue(); - } - - @Test - public void removingLoopingLastPeriodFromPlaylistDoesNotThrow() throws Exception { - Timeline timeline = - new FakeTimeline( - new TimelineWindowDefinition( - /* isSeekable= */ true, /* isDynamic= */ true, /* durationUs= */ 100_000)); - MediaSource mediaSource = new FakeMediaSource(timeline); - ConcatenatingMediaSource concatenatingMediaSource = new ConcatenatingMediaSource(mediaSource); - ActionSchedule actionSchedule = - new ActionSchedule.Builder("removingLoopingLastPeriodFromPlaylistDoesNotThrow") - .pause() - .waitForPlaybackState(Player.STATE_READY) - // Play almost to end to ensure the current period is fully buffered. - .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 90) - // Enable repeat mode to trigger the creation of new media periods. - .setRepeatMode(Player.REPEAT_MODE_ALL) - // Remove the media source. - .executeRunnable(concatenatingMediaSource::clear) + // Play almost to end to ensure the current period is fully buffered. + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 90) + // Enable repeat mode to trigger the creation of new media periods. + .setRepeatMode(Player.REPEAT_MODE_ALL) + // Remove the media source. + .executeRunnable(concatenatingMediaSource::clear) .build(); new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2612,7 +2842,7 @@ public void seekToUnpreparedWindowWithNonZeroOffsetInConcatenationStartsAtCorrec .pause() .waitForPlaybackState(Player.STATE_BUFFERING) .seek(/* positionMs= */ 10) - .waitForTimelineChanged() + .waitForSeekProcessed() .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) .waitForTimelineChanged() .waitForPlaybackState(Player.STATE_READY) @@ -2626,7 +2856,7 @@ public void run(SimpleExoPlayer player) { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2657,7 +2887,7 @@ public void seekToUnpreparedWindowWithMultiplePeriodsInConcatenationStartsAtCorr .waitForPlaybackState(Player.STATE_BUFFERING) // Seek 10ms into the second period. .seek(/* positionMs= */ periodDurationMs + 10) - .waitForTimelineChanged() + .waitForSeekProcessed() .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) .waitForTimelineChanged() .waitForPlaybackState(Player.STATE_READY) @@ -2672,7 +2902,7 @@ public void run(SimpleExoPlayer player) { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2773,10 +3003,10 @@ public void run(SimpleExoPlayer player) { player.addListener(eventListener); } }) - .seek(5_000) + .seek(/* positionMs= */ 5_000) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(fakeMediaSource) + .setMediaSources(fakeMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2970,6 +3200,17 @@ protected void onChildSourceInfoRefreshed( Void id, MediaSource mediaSource, Timeline timeline) { refreshSourceInfo(timeline); } + + @Override + public boolean isSingleWindow() { + return false; + } + + @Nullable + @Override + public Timeline getInitialTimeline() { + return Timeline.EMPTY; + } }; int[] currentWindowIndices = new int[1]; long[] currentPlaybackPositions = new long[1]; @@ -2979,6 +3220,8 @@ protected void onChildSourceInfoRefreshed( new ActionSchedule.Builder("testDelegatingMediaSourceApproach") .seek(/* windowIndex= */ 1, /* positionMs= */ 5000) .waitForSeekProcessed() + .waitForTimelineChanged( + /* expectedTimeline= */ null, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable( new PlayerRunnable() { @Override @@ -2991,13 +3234,15 @@ public void run(SimpleExoPlayer player) { .build(); ExoPlayerTestRunner exoPlayerTestRunner = new Builder() - .setMediaSource(delegatingMediaSource) + .setMediaSources(delegatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - exoPlayerTestRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertArrayEquals(new long[] {2}, windowCounts); assertArrayEquals(new int[] {seekToWindowIndex}, currentWindowIndices); assertArrayEquals(new long[] {5_000}, currentPlaybackPositions); @@ -3014,7 +3259,6 @@ public void testSeekTo_windowIndexIsReset_deprecated() throws Exception { new ActionSchedule.Builder("testSeekTo_windowIndexIsReset_deprecated") .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .waitForSeekProcessed() .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) .executeRunnable( new PlayerRunnable() { @@ -3035,7 +3279,7 @@ public void run(SimpleExoPlayer player) { }) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(loopingMediaSource) + .setMediaSources(loopingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -3056,7 +3300,6 @@ public void testSeekTo_windowIndexIsReset() throws Exception { new ActionSchedule.Builder("testSeekTo_windowIndexIsReset") .pause() .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) - .waitForSeekProcessed() .playUntilPosition(/* windowIndex= */ 1, /* positionMs= */ 5000) .executeRunnable( new PlayerRunnable() { @@ -3075,8 +3318,8 @@ public void run(SimpleExoPlayer player) { } }) .build(); - new ExoPlayerTestRunner.Builder() - .setMediaSource(loopingMediaSource) + new Builder() + .setMediaSources(loopingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -3178,7 +3421,7 @@ public boolean shouldStartPlayback( try { new ExoPlayerTestRunner.Builder() .setLoadControl(neverLoadingLoadControl) - .setMediaSource(chunkedMediaSource) + .setMediaSources(chunkedMediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -3217,74 +3460,2237 @@ public boolean shouldStartPlayback( new ExoPlayerTestRunner.Builder() .setLoadControl(neverLoadingOrPlayingLoadControl) - .setMediaSource(chunkedMediaSource) + .setMediaSources(chunkedMediaSource) .build(context) .start() // This throws if playback doesn't finish within timeout. .blockUntilEnded(TIMEOUT_MS); } - // Internal methods. + @Test + public void testMoveMediaItem() throws Exception { + TimelineWindowDefinition firstWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition secondWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + Timeline timeline1 = new FakeTimeline(firstWindowDefinition); + Timeline timeline2 = new FakeTimeline(secondWindowDefinition); + MediaSource mediaSource1 = new FakeMediaSource(timeline1); + MediaSource mediaSource2 = new FakeMediaSource(timeline2); + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMoveMediaItem") + .waitForTimelineChanged( + /* expectedTimeline= */ null, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .moveMediaItem(/* currentIndex= */ 0, /* newIndex= */ 1) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { - final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); - final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1)); - return builder - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setVideoSurface(surface1); - } - }) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setVideoSurface(surface2); - } - }); + Timeline expectedRealTimeline = new FakeTimeline(firstWindowDefinition, secondWindowDefinition); + Timeline expectedRealTimelineAfterMove = + new FakeTimeline(secondWindowDefinition, firstWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); } - private static void deliverBroadcast(Intent intent) { - ApplicationProvider.getApplicationContext().sendBroadcast(intent); - shadowOf(Looper.getMainLooper()).idle(); - } + @Test + public void testRemoveMediaItem() throws Exception { + TimelineWindowDefinition firstWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition secondWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition thirdWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + Timeline timeline1 = new FakeTimeline(firstWindowDefinition); + Timeline timeline2 = new FakeTimeline(secondWindowDefinition); + Timeline timeline3 = new FakeTimeline(thirdWindowDefinition); + MediaSource mediaSource1 = new FakeMediaSource(timeline1); + MediaSource mediaSource2 = new FakeMediaSource(timeline2); + MediaSource mediaSource3 = new FakeMediaSource(timeline3); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testRemoveMediaItems") + .waitForPlaybackState(Player.STATE_READY) + .removeMediaItem(/* index= */ 0) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource1, mediaSource2, mediaSource3) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - // Internal classes. + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedRealTimeline = + new FakeTimeline(firstWindowDefinition, secondWindowDefinition, thirdWindowDefinition); + Timeline expectedRealTimelineAfterRemove = + new FakeTimeline(secondWindowDefinition, thirdWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + } - private static final class PositionGrabbingMessageTarget extends PlayerTarget { + @Test + public void testRemoveMediaItems() throws Exception { + TimelineWindowDefinition firstWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition secondWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition thirdWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + Timeline timeline1 = new FakeTimeline(firstWindowDefinition); + Timeline timeline2 = new FakeTimeline(secondWindowDefinition); + Timeline timeline3 = new FakeTimeline(thirdWindowDefinition); + MediaSource mediaSource1 = new FakeMediaSource(timeline1); + MediaSource mediaSource2 = new FakeMediaSource(timeline2); + MediaSource mediaSource3 = new FakeMediaSource(timeline3); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testRemoveMediaItems") + .waitForPlaybackState(Player.STATE_READY) + .removeMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource1, mediaSource2, mediaSource3) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - public int windowIndex; - public long positionMs; - public int messageCount; + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedRealTimeline = + new FakeTimeline(firstWindowDefinition, secondWindowDefinition, thirdWindowDefinition); + Timeline expectedRealTimelineAfterRemove = new FakeTimeline(firstWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + } - public PositionGrabbingMessageTarget() { - windowIndex = C.INDEX_UNSET; - positionMs = C.POSITION_UNSET; - } + @Test + public void testClearMediaItems() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testClearMediaItems") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - @Override - public void handleMessage(SimpleExoPlayer player, int messageType, @Nullable Object message) { - if (player != null) { - windowIndex = player.getCurrentWindowIndex(); - positionMs = player.getCurrentPosition(); - } - messageCount++; - } + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */); } - private static final class PlayerStateGrabber extends PlayerRunnable { + @Test + public void testMultipleModificationWithRecursiveListenerInvocations() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource mediaSource = new FakeMediaSource(timeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMultipleModificationWithRecursiveListenerInvocations") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .clearMediaItems() + .setMediaSources(secondMediaSource) + .waitForTimelineChanged() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - public boolean playWhenReady; - @Player.State public int playbackState; - @Nullable public Timeline timeline; + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, + timeline, + Timeline.EMPTY, + new FakeMediaSource.InitialTimeline(secondTimeline), + secondTimeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } - @Override - public void run(SimpleExoPlayer player) { - playWhenReady = player.getPlayWhenReady(); - playbackState = player.getPlaybackState(); - timeline = player.getCurrentTimeline(); + @Test + public void testModifyPlaylistUnprepared_remainsInIdle_needsPrepareForBuffering() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + int[] playbackStates = new int[4]; + int[] timelineWindowCounts = new int[4]; + int[] maskingPlaybackState = {C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testModifyPlaylistUnprepared_remainsInIdle_needsPrepareForBuffering") + .waitForTimelineChanged(dummyTimeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 0, playbackStates, timelineWindowCounts)) + .clearMediaItems() + .executeRunnable( + new PlaybackStateCollector(/* index= */ 1, playbackStates, timelineWindowCounts)) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setMediaSource(firstMediaSource, /* startPositionMs= */ 1000); + maskingPlaybackState[0] = player.getPlaybackState(); + } + }) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 2, playbackStates, timelineWindowCounts)) + .addMediaSources(secondMediaSource) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 3, playbackStates, timelineWindowCounts)) + .seek(/* windowIndex= */ 1, /* positionMs= */ 2000) + .waitForSeekProcessed() + .prepare() + // The first expected buffering state arrives after prepare but not before. + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE}, + playbackStates); + assertArrayEquals(new int[] {1, 0, 1, 2}, timelineWindowCounts); + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING /* first buffering state after prepare */, + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* initial setMediaSources */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* clear */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* set media items */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* add media items */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source update after prepare */); + Timeline expectedSecondDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedSecondRealTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000)); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, + Timeline.EMPTY, + dummyTimeline, + expectedSecondDummyTimeline, + expectedSecondRealTimeline); + assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackState); + } + + @Test + public void testModifyPlaylistPrepared_remainsInEnded_needsSeekForBuffering() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + FakeMediaSource secondMediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testModifyPlaylistPrepared_remainsInEnded_needsSeekForBuffering") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .addMediaSources(secondMediaSource) // add must not transition to buffering + .waitForTimelineChanged() + .clearMediaItems() // clear must remain in ended + .addMediaSources(secondMediaSource) // add again to be able to test the seek + .waitForTimelineChanged() + .seek(/* positionMs= */ 2_000) // seek must transition to buffering + .waitForSeekProcessed() + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setExpectedPlayerEndedCount(/* expectedPlayerEndedCount= */ 2) + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, // first buffering + Player.STATE_READY, + Player.STATE_ENDED, // clear playlist + Player.STATE_BUFFERING, // second buffering after seek + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, + timeline, + Timeline.EMPTY, + dummyTimeline, + timeline, + Timeline.EMPTY, + dummyTimeline, + timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media items added */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media items added */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testStopWithNoReset_modifyingPlaylistRemainsInIdleState_needsPrepareForBuffering() + throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + FakeMediaSource secondMediaSource = new FakeMediaSource(timeline); + int[] playbackStateHolder = new int[3]; + int[] windowCountHolder = new int[3]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testStopWithNoReset_modifyingPlaylistRemainsInIdleState_needsPrepareForBuffering") + .waitForPlaybackState(Player.STATE_READY) + .stop(/* reset= */ false) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 0, playbackStateHolder, windowCountHolder)) + .clearMediaItems() + .executeRunnable( + new PlaybackStateCollector(/* index= */ 1, playbackStateHolder, windowCountHolder)) + .addMediaSources(secondMediaSource) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 2, playbackStateHolder, windowCountHolder)) + .prepare() + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE}, playbackStateHolder); + assertArrayEquals(new int[] {1, 0, 1}, windowCountHolder); + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, // first buffering + Player.STATE_READY, + Player.STATE_IDLE, // stop + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, timeline, Timeline.EMPTY, dummyTimeline, timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, /* source prepared */ + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* clear media items */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item add (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testPrepareWithInvalidInitialSeek_expectEndedImmediately() throws Exception { + final int[] currentWindowIndices = {C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testPrepareWithInvalidInitialSeek_expectEnded") + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .skipSettingMediaSources() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual(Player.STATE_IDLE, Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame(); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual(); + assertArrayEquals(new int[] {1}, currentWindowIndices); + } + + @Test + public void testPrepareWhenAlreadyPreparedIsANoop() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testPrepareWhenAlreadyPreparedIsANoop") + .waitForPlaybackState(Player.STATE_READY) + .prepare() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testSeekToIndexLargerThanNumberOfPlaylistItems() throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource( + /* isAtomic= */ false, + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT), + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + int[] currentWindowIndices = new int[1]; + long[] currentPlaybackPositions = new long[1]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexLargerThanNumberOfPlaylistItems") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + } + }) + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {5_000}, currentPlaybackPositions); + assertArrayEquals(new int[] {seekToWindowIndex}, currentWindowIndices); + } + + @Test + public void testSeekToIndexWithEmptyMultiWindowMediaSource() throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + int[] currentWindowIndices = new int[2]; + long[] currentPlaybackPositions = new long[2]; + long[] windowCounts = new long[2]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexWithEmptyMultiWindowMediaSource") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + windowCounts[0] = player.getCurrentTimeline().getWindowCount(); + } + }) + .executeRunnable( + () -> { + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPlaybackPositions[1] = player.getCurrentPosition(); + windowCounts[1] = player.getCurrentTimeline().getWindowCount(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {0, 2}, windowCounts); + assertArrayEquals(new int[] {seekToWindowIndex, seekToWindowIndex}, currentWindowIndices); + assertArrayEquals(new long[] {5_000, 5_000}, currentPlaybackPositions); + } + + @Test + public void testEmptyMultiWindowMediaSource_doesNotEnterBufferState() throws Exception { + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testEmptyMultiWindowMediaSource_doesNotEnterBufferState") + .waitForTimelineChanged() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(concatenatingMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + exoPlayerTestRunner.assertPlaybackStatesEqual(1, 4); + } + + @Test + public void testSeekToIndexWithEmptyMultiWindowMediaSource_usesLazyPreparation() + throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + int[] currentWindowIndices = new int[2]; + long[] currentPlaybackPositions = new long[2]; + long[] windowCounts = new long[2]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexWithEmptyMultiWindowMediaSource") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + windowCounts[0] = player.getCurrentTimeline().getWindowCount(); + } + }) + .executeRunnable( + () -> { + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPlaybackPositions[1] = player.getCurrentPosition(); + windowCounts[1] = player.getCurrentTimeline().getWindowCount(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .setUseLazyPreparation(/* useLazyPreparation= */ true) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {0, 2}, windowCounts); + assertArrayEquals(new int[] {seekToWindowIndex, seekToWindowIndex}, currentWindowIndices); + assertArrayEquals(new long[] {5_000, 5_000}, currentPlaybackPositions); + } + + @Test + public void testSetMediaSources_empty_whenEmpty_correctMaskingWindowIndex() throws Exception { + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_empty_whenEmpty_correctMaskingWindowIndex") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + List listOfTwo = new ArrayList<>(); + listOfTwo.add(secondMediaSource); + listOfTwo.add(secondMediaSource); + player.addMediaSources(/* index= */ 0, listOfTwo); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {0, 0, 0}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_empty_whenEmpty_validInitialSeek_correctMaskingWindowIndex() + throws Exception { + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_empty_whenEmpty_validInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + List listOfTwo = new ArrayList<>(); + listOfTwo.add(secondMediaSource); + listOfTwo.add(secondMediaSource); + player.addMediaSources(/* index= */ 0, listOfTwo); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 1, 1}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_empty_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex() + throws Exception { + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_empty_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + List listOfTwo = new ArrayList<>(); + listOfTwo.add(secondMediaSource); + listOfTwo.add(secondMediaSource); + player.addMediaSources(/* index= */ 0, listOfTwo); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 4, C.TIME_UNSET) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {4, 0, 0}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_whenEmpty_correctMaskingWindowIndex() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_whenEmpty_correctMaskingWindowIndex") + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Increase current window index. + player.addMediaSource(/* index= */ 0, secondMediaSource); + currentWindowIndices[0] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Current window index is unchanged. + player.addMediaSource(/* index= */ 2, secondMediaSource); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + MediaSource mediaSource = + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(mediaSource, mediaSource, mediaSource); + // Increase current window with multi window source. + player.addMediaSource(/* index= */ 0, concatenatingMediaSource); + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(); + // Current window index is unchanged when adding empty source. + player.addMediaSource(/* index= */ 0, concatenatingMediaSource); + currentWindowIndices[3] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 1, 4, 4}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_whenEmpty_validInitialSeek_correctMaskingWindowIndex() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 2); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenEmpty_validInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + // Increase current window index. + player.addMediaSource(/* index= */ 0, secondMediaSource); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 2, 2}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenEmpty_invalidInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + // Increase current window index. + player.addMediaSource(/* index= */ 0, secondMediaSource); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {0, 1, 1}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_correctMaskingWindowIndex() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_correctMaskingWindowIndex") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + // Increase current window index. + player.addMediaSource(/* index= */ 0, secondMediaSource); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {0, 1, 1}, currentWindowIndices); + } + + @Test + public void testSetMediaSources_whenIdle_correctMaskingPlaybackState() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, 1L); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, 2L); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] maskingPlaybackStates = new int[4]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_whenIdle_correctMaskingPlaybackState") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with no seek. + player.setMediaSource(new ConcatenatingMediaSource()); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an implicit seek to the current position. + player.setMediaSource(firstMediaSource); + maskingPlaybackStates[1] = player.getPlaybackState(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an explicit seek. + player.setMediaSource(secondMediaSource, /* startPositionMs= */ C.TIME_UNSET); + maskingPlaybackStates[2] = player.getPlaybackState(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with an explicit seek. + player.setMediaSource( + new ConcatenatingMediaSource(), /* startPositionMs= */ C.TIME_UNSET); + maskingPlaybackStates[3] = player.getPlaybackState(); + } + }) + .prepare() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .skipSettingMediaSources() + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual(Player.STATE_IDLE, Player.STATE_ENDED); + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE}, + maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + } + + @Test + public void testSetMediaSources_whenIdle_invalidSeek_correctMaskingPlaybackState() + throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenIdle_invalidSeek_correctMaskingPlaybackState") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set a media item with an implicit seek to the current position which is + // invalid in the new timeline. + player.setMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1, 1L))); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .prepare() + .waitForPlaybackState(Player.STATE_READY) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenIdle_noSeek_correctMaskingPlaybackState() throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenIdle_noSeek_correctMaskingPlaybackState") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with no seek. + player.setMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .prepare() + .waitForPlaybackState(Player.STATE_READY) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .skipSettingMediaSources() + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenIdle_noSeekEmpty_correctMaskingPlaybackState() + throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenIdle_noSeekEmpty_correctMaskingPlaybackState") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set an empty media item with no seek. + player.setMediaSource(new ConcatenatingMediaSource()); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .setMediaSources(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))) + .prepare() + .waitForPlaybackState(Player.STATE_READY) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .skipSettingMediaSources() + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_IDLE}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenEnded_correctMaskingPlaybackState() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, 1L); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, 2L); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] maskingPlaybackStates = new int[4]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_whenEnded_correctMaskingPlaybackState") + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with an implicit seek to the current position. + player.setMediaSource(new ConcatenatingMediaSource()); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with an explicit seek. + player.setMediaSource( + new ConcatenatingMediaSource(), /* startPositionMs= */ C.TIME_UNSET); + maskingPlaybackStates[1] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an implicit seek to the current position. + player.setMediaSource(firstMediaSource); + maskingPlaybackStates[2] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an explicit seek. + player.setMediaSource(secondMediaSource, /* startPositionMs= */ C.TIME_UNSET); + maskingPlaybackStates[3] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setExpectedPlayerEndedCount(/* expectedPlayerEndedCount= */ 3) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_ENDED, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED); + assertArrayEquals( + new int[] { + Player.STATE_ENDED, Player.STATE_ENDED, Player.STATE_BUFFERING, Player.STATE_BUFFERING + }, + maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenEnded_invalidSeek_correctMaskingPlaybackState() + throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenEnded_invalidSeek_correctMaskingPlaybackState") + .waitForSeekProcessed() + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an invalid implicit seek to the current position. + player.setMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1, 1L)), + /* resetPosition= */ false); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual(Player.STATE_IDLE, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenEnded_noSeek_correctMaskingPlaybackState() throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenEnded_noSeek_correctMaskingPlaybackState") + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with no seek (keep current position). + player.setMediaSource( + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)), + /* resetPosition= */ false); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testSetMediaSources_whenEnded_noSeekEmpty_correctMaskingPlaybackState() + throws Exception { + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenEnded_noSeekEmpty_correctMaskingPlaybackState") + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set an empty media item with no seek. + player.setMediaSource(new ConcatenatingMediaSource()); + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + } + + @Test + public void testSetMediaSources_whenPrepared_correctMaskingPlaybackState() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, 1L); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, 2L); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] maskingPlaybackStates = new int[4]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSetMediaSources_whenPrepared_correctMaskingPlaybackState") + .pause() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with an implicit seek to current position. + player.setMediaSource( + new ConcatenatingMediaSource(), /* resetPosition= */ false); + // Expect masking state is ended, + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .setMediaSources(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET, firstMediaSource) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set empty media item with an explicit seek. + player.setMediaSource( + new ConcatenatingMediaSource(), /* startPositionMs= */ C.TIME_UNSET); + // Expect masking state is ended, + maskingPlaybackStates[1] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .setMediaSources(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET, firstMediaSource) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an explicit seek. + player.setMediaSource(secondMediaSource, /* startPositionMs= */ C.TIME_UNSET); + // Expect masking state is buffering, + maskingPlaybackStates[2] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_READY) + .setMediaSources(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET, firstMediaSource) + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Set media item with an implicit seek to the current position. + player.setMediaSource(secondMediaSource, /* resetPosition= */ false); + // Expect masking state is buffering, + maskingPlaybackStates[3] = player.getPlaybackState(); + } + }) + .play() + .waitForPlaybackState(Player.STATE_READY) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setExpectedPlayerEndedCount(/* expectedPlayerEndedCount= */ 3) + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_IDLE, // Pause. + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready after initial prepare. + Player.STATE_ENDED, // Ended after setting empty source without seek. + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready again after re-setting source. + Player.STATE_ENDED, // Ended after setting empty source with seek. + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready again after re-setting source. + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready after setting media item with seek. + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready again after re-setting source. + Player.STATE_BUFFERING, + Player.STATE_BUFFERING, // Play. + Player.STATE_READY, // Ready after setting media item without seek. + Player.STATE_ENDED); + assertArrayEquals( + new int[] { + Player.STATE_ENDED, Player.STATE_ENDED, Player.STATE_BUFFERING, Player.STATE_BUFFERING + }, + maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, // Initial source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, // Empty source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, // Reset source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, // Empty source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, // Reset source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, // Set source with seek. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, // Reset source. + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); // Set source without seek. + } + + @Test + public void testSetMediaSources_whenPrepared_invalidSeek_correctMaskingPlaybackState() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, 1L); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + final int[] maskingPlaybackStates = new int[1]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testSetMediaSources_whenPrepared_invalidSeek_correctMaskingPlaybackState") + .pause() + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // An implicit, invalid seek picking up the position set by the initial seek. + player.setMediaSource(firstMediaSource, /* resetPosition= */ false); + // Expect masking state is ended, + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .setMediaSources(/* windowIndex= */ 0, /* positionMs= */ C.TIME_UNSET, firstMediaSource) + .waitForPlaybackState(Player.STATE_READY) + .play() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setExpectedPlayerEndedCount(/* expectedPlayerEndedCount= */ 2) + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(new ConcatenatingMediaSource()) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_IDLE, // Pause. + Player.STATE_ENDED, // Empty source has been prepared. + Player.STATE_BUFFERING, // After setting another source. + Player.STATE_READY, + Player.STATE_READY, // Play. + Player.STATE_ENDED); + assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackStates); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + } + + @Test + public void testAddMediaSources_skipSettingMediaItems_validInitialSeek_correctMaskingWindowIndex() + throws Exception { + final int[] currentWindowIndices = new int[5]; + Arrays.fill(currentWindowIndices, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testAddMediaSources_skipSettingMediaItems_validInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.addMediaSource(/* index= */ 0, new ConcatenatingMediaSource()); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + player.addMediaSource( + /* index= */ 0, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2))); + currentWindowIndices[2] = player.getCurrentWindowIndex(); + player.addMediaSource( + /* index= */ 0, + new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1))); + currentWindowIndices[3] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[4] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .skipSettingMediaSources() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 1, 1, 2, 2}, currentWindowIndices); + } + + @Test + public void + testAddMediaSources_skipSettingMediaItems_invalidInitialSeek_correctMaskingWindowIndex() + throws Exception { + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testAddMediaSources_skipSettingMediaItems_invalidInitialSeek_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.addMediaSource(secondMediaSource); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .skipSettingMediaSources() + .initialSeek(/* windowIndex= */ 1, C.TIME_UNSET) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 0, 0}, currentWindowIndices); + } + + @Test + public void testMoveMediaItems_correctMaskingWindowIndex() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(timeline); + MediaSource secondMediaSource = new FakeMediaSource(timeline); + MediaSource thirdMediaSource = new FakeMediaSource(timeline); + final int[] currentWindowIndices = { + C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET + }; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMoveMediaItems_correctMaskingWindowIndex") + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move the current item down in the playlist. + player.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 2, /* newIndex= */ 1); + currentWindowIndices[0] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move the current item up in the playlist. + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 0); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .seek(/* windowIndex= */ 2, C.TIME_UNSET) + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move items from before to behind the current item. + player.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 2, /* newIndex= */ 1); + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move items from behind to before the current item. + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 3, /* newIndex= */ 0); + currentWindowIndices[3] = player.getCurrentWindowIndex(); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move items from before to before the current item. + // No change in currentWindowIndex. + player.moveMediaItems(/* fromIndex= */ 0, /* toIndex= */ 1, /* newIndex= */ 1); + currentWindowIndices[4] = player.getCurrentWindowIndex(); + } + }) + .seek(/* windowIndex= */ 0, C.TIME_UNSET) + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Move items from behind to behind the current item. + // No change in currentWindowIndex. + player.moveMediaItems(/* fromIndex= */ 1, /* toIndex= */ 2, /* newIndex= */ 2); + currentWindowIndices[5] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .setMediaSources(firstMediaSource, secondMediaSource, thirdMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 0, 0, 2, 2, 0}, currentWindowIndices); + } + + @Test + public void testMoveMediaItems_unprepared_correctMaskingWindowIndex() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMoveMediaItems_unprepared_correctMaskingWindowIndex") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Increase current window index. + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.moveMediaItem(/* currentIndex= */ 0, /* newIndex= */ 1); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .prepare() + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[2] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {0, 1, 1}, currentWindowIndices); + } + + @Test + public void testRemoveMediaItems_correctMaskingWindowIndex() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testRemoveMediaItems_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Decrease current window index. + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.removeMediaItem(/* index= */ 0); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + } + + @Test + public void testRemoveMediaItems_currentItemRemoved_correctMaskingWindowIndex() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; + final long[] currentPositions = {C.TIME_UNSET, C.TIME_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testRemoveMediaItems_CurrentItemRemoved_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Remove the current item. + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPositions[0] = player.getCurrentPosition(); + player.removeMediaItem(/* index= */ 1); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPositions[1] = player.getCurrentPosition(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ 5000) + .setMediaSources(firstMediaSource, secondMediaSource, firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 1}, currentWindowIndices); + assertThat(currentPositions[0]).isAtLeast(5000L); + assertThat(currentPositions[1]).isEqualTo(0); + } + + @Test + public void testRemoveMediaItems_currentItemRemovedThatIsTheLast_correctMasking() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, 1L); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, 2L); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + Timeline thirdTimeline = new FakeTimeline(/* windowCount= */ 1, 3L); + MediaSource thirdMediaSource = new FakeMediaSource(thirdTimeline); + Timeline fourthTimeline = new FakeTimeline(/* windowCount= */ 1, 3L); + MediaSource fourthMediaSource = new FakeMediaSource(fourthTimeline); + final int[] currentWindowIndices = new int[9]; + Arrays.fill(currentWindowIndices, C.INDEX_UNSET); + final int[] maskingPlaybackStates = new int[4]; + Arrays.fill(maskingPlaybackStates, C.INDEX_UNSET); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testRemoveMediaItems_CurrentItemRemovedThatIsTheLast_correctMaskingWindowIndex") + .waitForSeekProcessed() + .waitForPlaybackState(Player.STATE_READY) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Expect the current window index to be 2 after seek. + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.removeMediaItem(/* index= */ 2); + // Expect the current window index to be 0 + // (default position of timeline after not finding subsequent period). + currentWindowIndices[1] = player.getCurrentWindowIndex(); + // Transition to ENDED. + maskingPlaybackStates[0] = player.getPlaybackState(); + } + }) + .waitForPlaybackState(Player.STATE_ENDED) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Expects the current window index still on 0. + currentWindowIndices[2] = player.getCurrentWindowIndex(); + // Insert an item at begin when the playlist is not empty. + player.addMediaSource(/* index= */ 0, thirdMediaSource); + // Expects the current window index to be (0 + 1) after insertion at begin. + currentWindowIndices[3] = player.getCurrentWindowIndex(); + // Remains in ENDED. + maskingPlaybackStates[1] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[4] = player.getCurrentWindowIndex(); + // Implicit seek to the current window index, which is out of bounds in new + // timeline. + player.setMediaSource(fourthMediaSource, /* resetPosition= */ false); + // 0 after reset. + currentWindowIndices[5] = player.getCurrentWindowIndex(); + // Invalid seek, so we remain in ENDED. + maskingPlaybackStates[2] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[6] = player.getCurrentWindowIndex(); + // Explicit seek to (0, C.TIME_UNSET). Player transitions to BUFFERING. + player.setMediaSource(fourthMediaSource, /* startPositionMs= */ 5000); + // 0 after explicit seek. + currentWindowIndices[7] = player.getCurrentWindowIndex(); + // Transitions from ENDED to BUFFERING after explicit seek. + maskingPlaybackStates[3] = player.getPlaybackState(); + } + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Check whether actual window index is equal masking index from above. + currentWindowIndices[8] = player.getCurrentWindowIndex(); + } + }) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .initialSeek(/* windowIndex= */ 2, /* positionMs= */ C.TIME_UNSET) + .setExpectedPlayerEndedCount(2) + .setMediaSources(firstMediaSource, secondMediaSource, thirdMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + // Expect reset of masking to first window. + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, + Player.STATE_READY, // Ready after initial prepare. + Player.STATE_ENDED, // ended after removing current window index + Player.STATE_BUFFERING, // buffers after set items with seek + Player.STATE_READY, + Player.STATE_ENDED); + assertArrayEquals( + new int[] { + Player.STATE_ENDED, // ended after removing current window index + Player.STATE_ENDED, // adding items does not change state + Player.STATE_ENDED, // set items with seek to current position. + Player.STATE_BUFFERING + }, // buffers after set items with seek + maskingPlaybackStates); + assertArrayEquals(new int[] {2, 0, 0, 1, 1, 0, 0, 0, 0}, currentWindowIndices); + } + + @Test + public void testRemoveMediaItems_removeTailWithCurrentWindow_whenIdle_finishesPlayback() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1, new Object()); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testRemoveMediaItems_removeTailWithCurrentWindow_whenIdle_finishesPlayback") + .seek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .waitForSeekProcessed() + .removeMediaItem(/* index= */ 1) + .prepare() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + } + + @Test + public void testClearMediaItems_correctMasking() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; + final int[] maskingPlaybackState = {C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testClearMediaItems_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + player.clearMediaItems(); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + maskingPlaybackState[0] = player.getPlaybackState(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + assertArrayEquals(new int[] {Player.STATE_ENDED}, maskingPlaybackState); + } + + @Test + public void testClearMediaItems_unprepared_correctMaskingWindowIndex_notEnded() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final int[] currentWindowIndices = {C.INDEX_UNSET, C.INDEX_UNSET}; + final int[] currentStates = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET}; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testClearMediaItems_correctMaskingWindowIndex") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentStates[0] = player.getPlaybackState(); + player.clearMediaItems(); + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentStates[1] = player.getPlaybackState(); + } + }) + .prepare() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + // Transitions to ended when prepared with zero media items. + currentStates[2] = player.getPlaybackState(); + } + }) + .build(); + new Builder() + .initialSeek(/* windowIndex= */ 1, /* positionMs= */ C.TIME_UNSET) + .setMediaSources(firstMediaSource, secondMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_ENDED}, currentStates); + assertArrayEquals(new int[] {1, 0}, currentWindowIndices); + } + + // Internal methods. + + private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { + final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); + final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1)); + return builder + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setVideoSurface(surface1); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setVideoSurface(surface2); + } + }); + } + + private static void deliverBroadcast(Intent intent) { + ApplicationProvider.getApplicationContext().sendBroadcast(intent); + shadowOf(Looper.getMainLooper()).idle(); + } + + // Internal classes. + + private static final class PositionGrabbingMessageTarget extends PlayerTarget { + + public int windowIndex; + public long positionMs; + public int messageCount; + + public PositionGrabbingMessageTarget() { + windowIndex = C.INDEX_UNSET; + positionMs = C.POSITION_UNSET; + } + + @Override + public void handleMessage(SimpleExoPlayer player, int messageType, @Nullable Object message) { + if (player != null) { + windowIndex = player.getCurrentWindowIndex(); + positionMs = player.getCurrentPosition(); + } + messageCount++; + } + } + + private static final class PlayerStateGrabber extends PlayerRunnable { + + public boolean playWhenReady; + @Player.State public int playbackState; + @Nullable public Timeline timeline; + + @Override + public void run(SimpleExoPlayer player) { + playWhenReady = player.getPlayWhenReady(); + playbackState = player.getPlaybackState(); + timeline = player.getCurrentTimeline(); + } + } + /** + * Provides a wrapper for a {@link Runnable} which does collect playback states and window counts. + * Can be used with {@link ActionSchedule.Builder#executeRunnable(Runnable)} to verify that a + * playback state did not change and hence no observable callback is called. + * + *

This is specifically useful in cases when the test may end before a given state arrives or + * when an action of the action schedule might execute before a callback is called. + */ + public static class PlaybackStateCollector extends PlayerRunnable { + + private final int[] playbackStates; + private final int[] timelineWindowCount; + private final int index; + + /** + * Creates the collector. + * + * @param index The index to populate. + * @param playbackStates An array of playback states to populate. + * @param timelineWindowCount An array of window counts to populate. + */ + public PlaybackStateCollector(int index, int[] playbackStates, int[] timelineWindowCount) { + Assertions.checkArgument(playbackStates.length > index && timelineWindowCount.length > index); + this.playbackStates = playbackStates; + this.timelineWindowCount = timelineWindowCount; + this.index = index; + } + + @Override + public void run(SimpleExoPlayer player) { + playbackStates[index] = player.getPlaybackState(); + timelineWindowCount[index] = player.getCurrentTimeline().getWindowCount(); } } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index 56726e39140..904702a9d5f 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -21,15 +21,17 @@ import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; +import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; +import java.util.Collections; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,19 +52,20 @@ public final class MediaPeriodQueueTest { private MediaPeriodQueue mediaPeriodQueue; private AdPlaybackState adPlaybackState; - private Timeline timeline; private Object periodUid; private PlaybackInfo playbackInfo; private RendererCapabilities[] rendererCapabilities; private TrackSelector trackSelector; private Allocator allocator; - private MediaSource mediaSource; + private Playlist playlist; + private FakeMediaSource fakeMediaSource; + private Playlist.MediaSourceHolder mediaSourceHolder; @Before public void setUp() { mediaPeriodQueue = new MediaPeriodQueue(); - mediaSource = mock(MediaSource.class); + playlist = mock(Playlist.class); rendererCapabilities = new RendererCapabilities[0]; trackSelector = mock(TrackSelector.class); allocator = mock(Allocator.class); @@ -70,7 +73,7 @@ public void setUp() { @Test public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { - setupTimeline(/* initialPositionUs= */ 0); + setupTimeline(); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_UNSET, @@ -81,7 +84,7 @@ public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { @Test public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos() { - setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs...= */ 0); + setupTimeline(/* adGroupTimesUs...= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd(/* adGroupIndex= */ 0, /* contentPositionUs= */ 0); advance(); @@ -95,10 +98,7 @@ public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos( @Test public void getNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, @@ -133,10 +133,7 @@ public void getNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos @Test public void getNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPeriodInfos() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - C.TIME_END_OF_SOURCE); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, C.TIME_END_OF_SOURCE); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, @@ -169,7 +166,7 @@ public void getNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPer @Test public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { - setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE); + setupTimeline(/* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_END_OF_SOURCE, @@ -189,10 +186,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeAfterReadingPeriod_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -202,10 +196,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP enqueueNext(); // Second ad. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US + 1); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US + 1); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -219,10 +211,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeBeforeReadingPeriod_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -233,10 +222,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading first ad. // Change position of first ad (= change duration of content before first ad). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US + 1, - SECOND_AD_START_TIME_US); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US + 1, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -250,10 +237,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeInReadingPeriodAfterReadingPosition_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -265,10 +249,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); long readingPositionAtStartOfContentBetweenAds = FIRST_AD_START_TIME_US + AD_DURATION_US; @@ -284,10 +266,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeInReadingPeriodBeforeReadingPosition_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -299,10 +278,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); long readingPositionAtEndOfContentBetweenAds = SECOND_AD_START_TIME_US + AD_DURATION_US; @@ -318,10 +295,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeInReadingPeriodReadToEnd_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -333,10 +307,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs...= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -347,16 +319,25 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP assertThat(getQueueLength()).isEqualTo(3); } - private void setupTimeline(long initialPositionUs, long... adGroupTimesUs) { + private void setupTimeline(long... adGroupTimesUs) { adPlaybackState = new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); - timeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + + // Create a media source holder. + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + fakeMediaSource = new FakeMediaSource(adTimeline); + mediaSourceHolder = new Playlist.MediaSourceHolder(fakeMediaSource, false); + mediaSourceHolder.mediaSource.prepareSourceInternal(/* mediaTransferListener */ null); + + Timeline timeline = createPlaylistTimeline(); periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); mediaPeriodQueue.setTimeline(timeline); + playbackInfo = new PlaybackInfo( timeline, - mediaPeriodQueue.resolveMediaPeriodIdForAds(periodUid, initialPositionUs), + mediaPeriodQueue.resolveMediaPeriodIdForAds(periodUid, /* positionUs= */ 0), /* startPositionUs= */ 0, /* contentPositionUs= */ 0, Player.STATE_READY, @@ -370,6 +351,25 @@ private void setupTimeline(long initialPositionUs, long... adGroupTimesUs) { /* positionUs= */ 0); } + private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) { + adPlaybackState = + new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); + updateTimeline(); + } + + private void updateTimeline() { + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + fakeMediaSource.setNewSourceInfo(adTimeline, /* manifest */ null); + mediaPeriodQueue.setTimeline(createPlaylistTimeline()); + } + + private Playlist.PlaylistTimeline createPlaylistTimeline() { + return new Playlist.PlaylistTimeline( + Collections.singleton(mediaSourceHolder), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + } + private void advance() { enqueueNext(); if (mediaPeriodQueue.getLoadingPeriod() != mediaPeriodQueue.getPlayingPeriod()) { @@ -390,7 +390,7 @@ private void enqueueNext() { rendererCapabilities, trackSelector, allocator, - mediaSource, + playlist, getNextMediaPeriodInfo(), new TrackSelectorResult( new RendererConfiguration[0], new TrackSelection[0], /* info= */ null)); @@ -422,11 +422,6 @@ private void setAdGroupFailedToLoad(int adGroupIndex) { updateTimeline(); } - private void updateTimeline() { - timeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); - mediaPeriodQueue.setTimeline(timeline); - } - private void assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( long startPositionUs, long endPositionUs, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java index 2e26529a818..b34a7fb8994 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -44,7 +44,6 @@ import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; -import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner.Builder; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeRenderer; import com.google.android.exoplayer2.testutil.FakeTimeline; @@ -133,24 +132,29 @@ public void testEmptyTimeline() throws Exception { assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* DYNAMIC */); listener.assertNoMoreEvents(); } @Test public void testSinglePeriod() throws Exception { FakeMediaSource mediaSource = - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT); + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, period0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0 /* started */, period0 /* stopped */); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0); @@ -179,9 +183,14 @@ public void testSinglePeriod() throws Exception { public void testAutomaticPeriodTransition() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT), new FakeMediaSource( - SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT)); + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT), + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); populateEventIds(listener.lastReportedTimeline); @@ -191,7 +200,8 @@ public void testAutomaticPeriodTransition() throws Exception { WINDOW_0 /* BUFFERING */, period0 /* READY */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0, period0, period0, period0); @@ -233,8 +243,8 @@ public void testAutomaticPeriodTransition() throws Exception { public void testPeriodTransitionWithRendererChange() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.AUDIO_FORMAT)); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); populateEventIds(listener.lastReportedTimeline); @@ -246,7 +256,8 @@ public void testPeriodTransitionWithRendererChange() throws Exception { period1 /* BUFFERING */, period1 /* READY */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0, period0, period0, period0); @@ -286,8 +297,8 @@ public void testPeriodTransitionWithRendererChange() throws Exception { public void testSeekToOtherPeriod() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.AUDIO_FORMAT)); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .pause() @@ -308,7 +319,8 @@ public void testSeekToOtherPeriod() throws Exception { period1 /* READY */, period1 /* setPlayWhenReady=true */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period1); @@ -350,9 +362,11 @@ public void testSeekToOtherPeriod() throws Exception { public void testSeekBackAfterReadingAhead() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), new FakeMediaSource( - SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT)); + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); long periodDurationMs = SINGLE_PERIOD_TIMELINE.getWindow(/* windowIndex= */ 0, new Window()).getDurationMs(); ActionSchedule actionSchedule = @@ -380,7 +394,8 @@ public void testSeekBackAfterReadingAhead() throws Exception { period1Seq2 /* BUFFERING */, period1Seq2 /* READY */, period1Seq2 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly(period0, period1Seq2); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); @@ -428,18 +443,28 @@ public void testSeekBackAfterReadingAhead() throws Exception { @Test public void testPrepareNewSource() throws Exception { - MediaSource mediaSource1 = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); - MediaSource mediaSource2 = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + MediaSource mediaSource1 = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); + MediaSource mediaSource2 = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .pause() .waitForPlaybackState(Player.STATE_READY) - .prepareSource(mediaSource2) + .setMediaSources(/* resetPosition= */ false, mediaSource2) .play() .build(); TestAnalyticsListener listener = runAnalyticsTest(mediaSource1, actionSchedule); - populateEventIds(SINGLE_PERIOD_TIMELINE); + // Populate all event ids with last timeline (after second prepare). + populateEventIds(listener.lastReportedTimeline); + // Populate event id of period 0, sequence 0 with timeline of initial preparation. + period0Seq0 = + new EventWindowAndPeriodId( + /* windowIndex= */ 0, + new MediaPeriodId( + listener.reportedTimelines.get(1).getUidOfPeriod(/* periodIndex= */ 0), + /* windowSequenceNumber= */ 0)); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady=true */, @@ -451,12 +476,16 @@ public void testPrepareNewSource() throws Exception { period0Seq1 /* READY */, period0Seq1 /* ENDED */); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* reset */, WINDOW_0 /* prepared */); + .containsExactly( + WINDOW_0 /* PLAYLIST_CHANGE */, + WINDOW_0 /* DYNAMIC */, + WINDOW_0 /* PLAYLIST_CHANGE */, + WINDOW_0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( - period0Seq0 /* prepared */, WINDOW_0 /* reset */, period0Seq1 /* prepared */); + period0Seq0 /* prepared */, WINDOW_0 /* setMediaSources */, period0Seq1 /* prepared */); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, @@ -490,19 +519,20 @@ public void testPrepareNewSource() throws Exception { @Test public void testReprepareAfterError() throws Exception { - MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) .seek(/* positionMs= */ 0) - .prepareSource(mediaSource, /* resetPosition= */ false, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_ENDED) .build(); TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady=true */, @@ -556,7 +586,7 @@ public void testReprepareAfterError() throws Exception { @Test public void testDynamicTimelineChange() throws Exception { MediaSource childMediaSource = - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); final ConcatenatingMediaSource concatenatedMediaSource = new ConcatenatingMediaSource(childMediaSource, childMediaSource); long periodDurationMs = @@ -588,7 +618,11 @@ public void testDynamicTimelineChange() throws Exception { period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* BUFFERING */, period1Seq0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0, period1Seq0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly( + WINDOW_0 /* PLAYLIST_CHANGED */, + window0Period1Seq0 /* DYNAMIC (concatenated timeline replaces dummy) */, + period1Seq0 /* DYNAMIC (child sources in concatenating source moved) */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( window0Period1Seq0, window0Period1Seq0, window0Period1Seq0, window0Period1Seq0); @@ -642,7 +676,7 @@ public void run(SimpleExoPlayer player) { .build(); TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); } @@ -709,7 +743,7 @@ private static TestAnalyticsListener runAnalyticsTest( TestAnalyticsListener listener = new TestAnalyticsListener(); try { new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderersFactory(renderersFactory) .setAnalyticsListener(listener) .setActionSchedule(actionSchedule) @@ -731,7 +765,7 @@ private static final class FakeVideoRenderer extends FakeRenderer { private boolean renderedFirstFrame; public FakeVideoRenderer(Handler handler, VideoRendererEventListener eventListener) { - super(Builder.VIDEO_FORMAT); + super(ExoPlayerTestRunner.Builder.VIDEO_FORMAT); eventDispatcher = new VideoRendererEventListener.EventDispatcher(handler, eventListener); decoderCounters = new DecoderCounters(); } @@ -789,7 +823,7 @@ private static final class FakeAudioRenderer extends FakeRenderer { private boolean notifiedAudioSessionId; public FakeAudioRenderer(Handler handler, AudioRendererEventListener eventListener) { - super(Builder.AUDIO_FORMAT); + super(ExoPlayerTestRunner.Builder.AUDIO_FORMAT); eventDispatcher = new AudioRendererEventListener.EventDispatcher(handler, eventListener); decoderCounters = new DecoderCounters(); } @@ -873,10 +907,12 @@ private static final class TestAnalyticsListener implements AnalyticsListener { public Timeline lastReportedTimeline; + private final List reportedTimelines; private final ArrayList reportedEvents; public TestAnalyticsListener() { reportedEvents = new ArrayList<>(); + reportedTimelines = new ArrayList<>(); lastReportedTimeline = Timeline.EMPTY; } @@ -906,6 +942,7 @@ public void onPlayerStateChanged( @Override public void onTimelineChanged(EventTime eventTime, int reason) { lastReportedTimeline = eventTime.timeline; + reportedTimelines.add(eventTime.timeline); reportedEvents.add(new ReportedEvent(EVENT_TIMELINE_CHANGED, eventTime)); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index 20b80ace52d..08148493bb6 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java @@ -21,6 +21,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.IllegalSeekPositionException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.PlayerMessage; @@ -29,6 +30,7 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.ActionSchedule.ActionNode; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; @@ -38,6 +40,8 @@ import com.google.android.exoplayer2.util.HandlerWrapper; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.List; /** Base class for actions to perform during playback tests. */ public abstract class Action { @@ -114,6 +118,7 @@ public static final class Seek extends Action { private final Integer windowIndex; private final long positionMs; + private final boolean catchIllegalSeekException; /** * Action calls {@link Player#seekTo(long)}. @@ -125,6 +130,7 @@ public Seek(String tag, long positionMs) { super(tag, "Seek:" + positionMs); this.windowIndex = null; this.positionMs = positionMs; + catchIllegalSeekException = false; } /** @@ -133,24 +139,191 @@ public Seek(String tag, long positionMs) { * @param tag A tag to use for logging. * @param windowIndex The window to seek to. * @param positionMs The seek position. + * @param catchIllegalSeekException Whether {@link IllegalSeekPositionException} should be + * silently caught or not. */ - public Seek(String tag, int windowIndex, long positionMs) { + public Seek(String tag, int windowIndex, long positionMs, boolean catchIllegalSeekException) { super(tag, "Seek:" + positionMs); this.windowIndex = windowIndex; this.positionMs = positionMs; + this.catchIllegalSeekException = catchIllegalSeekException; } @Override protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { - if (windowIndex == null) { - player.seekTo(positionMs); - } else { - player.seekTo(windowIndex, positionMs); + try { + if (windowIndex == null) { + player.seekTo(positionMs); + } else { + player.seekTo(windowIndex, positionMs); + } + } catch (IllegalSeekPositionException e) { + if (!catchIllegalSeekException) { + throw e; + } } } } + /** Calls {@link SimpleExoPlayer#setMediaSources(List, int, long)}. */ + public static final class SetMediaItems extends Action { + + private final int windowIndex; + private final long positionMs; + private final MediaSource[] mediaSources; + + /** + * @param tag A tag to use for logging. + * @param windowIndex The window index to start playback from. + * @param positionMs The position in milliseconds to start playback from. + * @param mediaSources The media sources to populate the playlist with. + */ + public SetMediaItems( + String tag, int windowIndex, long positionMs, MediaSource... mediaSources) { + super(tag, "SetMediaItems"); + this.windowIndex = windowIndex; + this.positionMs = positionMs; + this.mediaSources = mediaSources; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.setMediaSources(Arrays.asList(mediaSources), windowIndex, positionMs); + } + } + + /** Calls {@link SimpleExoPlayer#addMediaSources(List)}. */ + public static final class AddMediaItems extends Action { + + private final MediaSource[] mediaSources; + + /** + * @param tag A tag to use for logging. + * @param mediaSources The media sources to be added to the playlist. + */ + public AddMediaItems(String tag, MediaSource... mediaSources) { + super(tag, /* description= */ "AddMediaItems"); + this.mediaSources = mediaSources; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.addMediaSources(Arrays.asList(mediaSources)); + } + } + + /** Calls {@link SimpleExoPlayer#setMediaSources(List, boolean)}. */ + public static final class SetMediaItemsResetPosition extends Action { + + private final boolean resetPosition; + private final MediaSource[] mediaSources; + + /** + * @param tag A tag to use for logging. + * @param resetPosition Whether the position should be reset. + * @param mediaSources The media sources to populate the playlist with. + */ + public SetMediaItemsResetPosition( + String tag, boolean resetPosition, MediaSource... mediaSources) { + super(tag, "SetMediaItems"); + this.resetPosition = resetPosition; + this.mediaSources = mediaSources; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.setMediaSources(Arrays.asList(mediaSources), resetPosition); + } + } + + /** Calls {@link SimpleExoPlayer#moveMediaItem(int, int)}. */ + public static class MoveMediaItem extends Action { + + private final int currentIndex; + private final int newIndex; + + /** + * @param tag A tag to use for logging. + * @param currentIndex The current index of the media item. + * @param newIndex The new index of the media item. + */ + public MoveMediaItem(String tag, int currentIndex, int newIndex) { + super(tag, "MoveMediaItem"); + this.currentIndex = currentIndex; + this.newIndex = newIndex; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.moveMediaItem(currentIndex, newIndex); + } + } + + /** Calls {@link SimpleExoPlayer#removeMediaItem(int)}. */ + public static class RemoveMediaItem extends Action { + + private final int index; + + /** + * @param tag A tag to use for logging. + * @param index The index of the item to remove. + */ + public RemoveMediaItem(String tag, int index) { + super(tag, "RemoveMediaItem"); + this.index = index; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.removeMediaItem(index); + } + } + + /** Calls {@link SimpleExoPlayer#removeMediaItems(int, int)}. */ + public static class RemoveMediaItems extends Action { + + private final int fromIndex; + private final int toIndex; + + /** + * @param tag A tag to use for logging. + * @param fromIndex The start if the range of media items to remove. + * @param toIndex The end of the range of media items to remove (exclusive). + */ + public RemoveMediaItems(String tag, int fromIndex, int toIndex) { + super(tag, "RemoveMediaItem"); + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.removeMediaItems(fromIndex, toIndex); + } + } + + /** Calls {@link SimpleExoPlayer#clearMediaItems()}}. */ + public static class ClearMediaItems extends Action { + + /** @param tag A tag to use for logging. */ + public ClearMediaItems(String tag) { + super(tag, "ClearMediaItems"); + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.clearMediaItems(); + } + } + /** Calls {@link Player#stop()} or {@link Player#stop(boolean)}. */ public static final class Stop extends Action { @@ -209,7 +382,6 @@ protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlayWhenReady(playWhenReady); } - } /** @@ -295,52 +467,31 @@ protected void doActionImpl( } } - /** Calls {@link ExoPlayer#prepare(MediaSource)}. */ - public static final class PrepareSource extends Action { - - private final MediaSource mediaSource; - private final boolean resetPosition; - private final boolean resetState; - - /** - * @param tag A tag to use for logging. - * @param mediaSource The {@link MediaSource} to prepare the player with. - */ - public PrepareSource(String tag, MediaSource mediaSource) { - this(tag, mediaSource, true, true); - } - - /** - * @param tag A tag to use for logging. - * @param mediaSource The {@link MediaSource} to prepare the player with. - * @param resetPosition Whether the player's position should be reset. - */ - public PrepareSource( - String tag, MediaSource mediaSource, boolean resetPosition, boolean resetState) { - super(tag, "PrepareSource"); - this.mediaSource = mediaSource; - this.resetPosition = resetPosition; - this.resetState = resetState; + /** Calls {@link ExoPlayer#prepare()}. */ + public static final class Prepare extends Action { + /** @param tag A tag to use for logging. */ + public Prepare(String tag) { + super(tag, "Prepare"); } @Override protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { - player.prepare(mediaSource, resetPosition, resetState); + player.prepare(); } } /** Calls {@link Player#setRepeatMode(int)}. */ public static final class SetRepeatMode extends Action { - private final @Player.RepeatMode int repeatMode; + @Player.RepeatMode private final int repeatMode; /** * @param tag A tag to use for logging. * @param repeatMode The repeat mode. */ public SetRepeatMode(String tag, @Player.RepeatMode int repeatMode) { - super(tag, "SetRepeatMode:" + repeatMode); + super(tag, "SetRepeatMode: " + repeatMode); this.repeatMode = repeatMode; } @@ -351,6 +502,27 @@ protected void doActionImpl( } } + /** Calls {@link ExoPlayer#setShuffleOrder(ShuffleOrder)} . */ + public static final class SetShuffleOrder extends Action { + + private final ShuffleOrder shuffleOrder; + + /** + * @param tag A tag to use for logging. + * @param shuffleOrder The shuffle order. + */ + public SetShuffleOrder(String tag, ShuffleOrder shuffleOrder) { + super(tag, "SetShufflerOrder"); + this.shuffleOrder = shuffleOrder; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.setShuffleOrder(shuffleOrder); + } + } + /** Calls {@link Player#setShuffleModeEnabled(boolean)}. */ public static final class SetShuffleModeEnabled extends Action { @@ -361,7 +533,7 @@ public static final class SetShuffleModeEnabled extends Action { * @param shuffleModeEnabled Whether shuffling is enabled. */ public SetShuffleModeEnabled(String tag, boolean shuffleModeEnabled) { - super(tag, "SetShuffleModeEnabled:" + shuffleModeEnabled); + super(tag, "SetShuffleModeEnabled: " + shuffleModeEnabled); this.shuffleModeEnabled = shuffleModeEnabled; } @@ -448,7 +620,6 @@ protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlaybackParameters(playbackParameters); } - } /** Throws a playback exception on the playback thread. */ @@ -546,17 +717,34 @@ protected void doActionImpl( public static final class WaitForTimelineChanged extends Action { @Nullable private final Timeline expectedTimeline; + private final boolean ignoreExpectedReason; + @Player.TimelineChangeReason private final int expectedReason; + + /** + * Creates action waiting for a timeline change for a given reason. + * + * @param tag A tag to use for logging. + * @param expectedTimeline The expected timeline or null if any timeline change is relevant. + * @param expectedReason The expected timeline change reason. + */ + public WaitForTimelineChanged( + String tag, Timeline expectedTimeline, @Player.TimelineChangeReason int expectedReason) { + super(tag, "WaitForTimelineChanged"); + this.expectedTimeline = expectedTimeline != null ? new NoUidTimeline(expectedTimeline) : null; + this.ignoreExpectedReason = false; + this.expectedReason = expectedReason; + } /** - * Creates action waiting for a timeline change. + * Creates action waiting for any timeline change for any reason. * * @param tag A tag to use for logging. - * @param expectedTimeline The expected timeline to wait for. If null, wait for any timeline - * change. */ - public WaitForTimelineChanged(String tag, @Nullable Timeline expectedTimeline) { + public WaitForTimelineChanged(String tag) { super(tag, "WaitForTimelineChanged"); - this.expectedTimeline = expectedTimeline; + this.expectedTimeline = null; + this.ignoreExpectedReason = true; + this.expectedReason = Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; } @Override @@ -574,14 +762,16 @@ protected void doActionAndScheduleNextImpl( @Override public void onTimelineChanged( Timeline timeline, @Player.TimelineChangeReason int reason) { - if (expectedTimeline == null || timeline.equals(expectedTimeline)) { + if ((expectedTimeline == null || new NoUidTimeline(timeline).equals(expectedTimeline)) + && (ignoreExpectedReason || expectedReason == reason)) { player.removeListener(this); nextAction.schedule(player, trackSelector, surface, handler); } } }; player.addListener(listener); - if (expectedTimeline != null && player.getCurrentTimeline().equals(expectedTimeline)) { + Timeline currentTimeline = new NoUidTimeline(player.getCurrentTimeline()); + if (currentTimeline.equals(expectedTimeline)) { player.removeListener(listener); nextAction.schedule(player, trackSelector, surface, handler); } @@ -731,6 +921,50 @@ protected void doActionImpl( } } + /** + * Waits for a player message to arrive. If the target already received a message, the action + * returns immediately. + */ + public static final class WaitForMessage extends Action { + + private final PlayerTarget playerTarget; + + /** + * @param tag A tag to use for logging. + * @param playerTarget The target to observe. + */ + public WaitForMessage(String tag, PlayerTarget playerTarget) { + super(tag, "WaitForMessage"); + this.playerTarget = playerTarget; + } + + @Override + protected void doActionAndScheduleNextImpl( + final SimpleExoPlayer player, + final DefaultTrackSelector trackSelector, + final Surface surface, + final HandlerWrapper handler, + final ActionNode nextAction) { + if (nextAction == null) { + return; + } + PlayerTarget.Callback callback = + new PlayerTarget.Callback() { + @Override + public void onMessageArrived() { + nextAction.schedule(player, trackSelector, surface, handler); + } + }; + playerTarget.setCallback(callback); + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + // Not triggered. + } + } + /** * Waits for a specified loading state, returning either immediately or after a call to {@link * Player.EventListener#onLoadingChanged(boolean)}. @@ -816,7 +1050,7 @@ protected void doActionImpl( } } - /** Calls {@link Runnable#run()}. */ + /** Calls {@code Runnable.run()}. */ public static final class ExecuteRunnable extends Action { private final Runnable runnable; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java index f6ab4b9924e..9a9cfd50a41 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java @@ -28,10 +28,10 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.Action.ClearVideoSurface; import com.google.android.exoplayer2.testutil.Action.ExecuteRunnable; import com.google.android.exoplayer2.testutil.Action.PlayUntilPosition; -import com.google.android.exoplayer2.testutil.Action.PrepareSource; import com.google.android.exoplayer2.testutil.Action.Seek; import com.google.android.exoplayer2.testutil.Action.SendMessages; import com.google.android.exoplayer2.testutil.Action.SetAudioAttributes; @@ -40,10 +40,12 @@ import com.google.android.exoplayer2.testutil.Action.SetRendererDisabled; import com.google.android.exoplayer2.testutil.Action.SetRepeatMode; import com.google.android.exoplayer2.testutil.Action.SetShuffleModeEnabled; +import com.google.android.exoplayer2.testutil.Action.SetShuffleOrder; import com.google.android.exoplayer2.testutil.Action.SetVideoSurface; import com.google.android.exoplayer2.testutil.Action.Stop; import com.google.android.exoplayer2.testutil.Action.ThrowPlaybackException; import com.google.android.exoplayer2.testutil.Action.WaitForIsLoading; +import com.google.android.exoplayer2.testutil.Action.WaitForMessage; import com.google.android.exoplayer2.testutil.Action.WaitForPlayWhenReady; import com.google.android.exoplayer2.testutil.Action.WaitForPlaybackState; import com.google.android.exoplayer2.testutil.Action.WaitForPositionDiscontinuity; @@ -172,7 +174,19 @@ public Builder seek(long positionMs) { * @return The builder, for convenience. */ public Builder seek(int windowIndex, long positionMs) { - return apply(new Seek(tag, windowIndex, positionMs)); + return apply(new Seek(tag, windowIndex, positionMs, /* catchIllegalSeekException= */ false)); + } + + /** + * Schedules a seek action to be executed. + * + * @param windowIndex The window to seek to. + * @param positionMs The seek position. + * @param catchIllegalSeekException Whether an illegal seek position should be caught or not. + * @return The builder, for convenience. + */ + public Builder seek(int windowIndex, long positionMs, boolean catchIllegalSeekException) { + return apply(new Seek(tag, windowIndex, positionMs, catchIllegalSeekException)); } /** @@ -313,23 +327,99 @@ public Builder setAudioAttributes(AudioAttributes audioAttributes, boolean handl } /** - * Schedules a new source preparation action. + * Schedules a set media items action to be executed. + * + * @param windowIndex The window index to start playback from or {@link C#INDEX_UNSET} if the + * playback position should not be reset. + * @param positionMs The position in milliseconds from where playback should start. If {@link + * C#TIME_UNSET} is passed the default position is used. In any case, if {@code windowIndex} + * is set to {@link C#INDEX_UNSET} the position is not reset at all and this parameter is + * ignored. + * @return The builder, for convenience. + */ + public Builder setMediaSources(int windowIndex, long positionMs, MediaSource... sources) { + return apply(new Action.SetMediaItems(tag, windowIndex, positionMs, sources)); + } + + /** + * Schedules a set media items action to be executed. + * + * @param resetPosition Whether the playback position should be reset. + * @return The builder, for convenience. + */ + public Builder setMediaSources(boolean resetPosition, MediaSource... sources) { + return apply(new Action.SetMediaItemsResetPosition(tag, resetPosition, sources)); + } + + /** + * Schedules a set media items action to be executed. + * + * @param mediaSources The media sources to add. + * @return The builder, for convenience. + */ + public Builder setMediaSources(MediaSource... mediaSources) { + return apply( + new Action.SetMediaItems( + tag, /* windowIndex= */ C.INDEX_UNSET, /* positionMs= */ C.TIME_UNSET, mediaSources)); + } + /** + * Schedules a add media items action to be executed. + * + * @param mediaSources The media sources to add. + * @return The builder, for convenience. + */ + public Builder addMediaSources(MediaSource... mediaSources) { + return apply(new Action.AddMediaItems(tag, mediaSources)); + } + + /** + * Schedules a move media item action to be executed. + * + * @param currentIndex The current index of the item to move. + * @param newIndex The index after the item has been moved. + * @return The builder, for convenience. + */ + public Builder moveMediaItem(int currentIndex, int newIndex) { + return apply(new Action.MoveMediaItem(tag, currentIndex, newIndex)); + } + + /** + * Schedules a remove media item action to be executed. + * + * @param index The index of the media item to be removed. + * @return The builder, for convenience. + */ + public Builder removeMediaItem(int index) { + return apply(new Action.RemoveMediaItem(tag, index)); + } + + /** + * Schedules a remove media items action to be executed. * + * @param fromIndex The start of the range of media items to be removed. + * @param toIndex The end of the range of media items to be removed (exclusive). * @return The builder, for convenience. */ - public Builder prepareSource(MediaSource mediaSource) { - return apply(new PrepareSource(tag, mediaSource)); + public Builder removeMediaItems(int fromIndex, int toIndex) { + return apply(new Action.RemoveMediaItems(tag, fromIndex, toIndex)); } /** - * Schedules a new source preparation action. + * Schedules a prepare action to be executed. * - * @see com.google.android.exoplayer2.ExoPlayer#prepare(MediaSource, boolean, boolean) * @return The builder, for convenience. */ - public Builder prepareSource( - MediaSource mediaSource, boolean resetPosition, boolean resetState) { - return apply(new PrepareSource(tag, mediaSource, resetPosition, resetState)); + public Builder prepare() { + return apply(new Action.Prepare(tag)); + } + + /** + * Schedules a clear media items action to be created. + * + * @return The builder. for convenience, + */ + public Builder clearMediaItems() { + return apply(new Action.ClearMediaItems(tag)); } /** @@ -342,7 +432,17 @@ public Builder setRepeatMode(@Player.RepeatMode int repeatMode) { } /** - * Schedules a shuffle setting action. + * Schedules a set shuffle order action to be executed. + * + * @param shuffleOrder The shuffle order. + * @return The builder, for convenience. + */ + public Builder setShuffleOrder(ShuffleOrder shuffleOrder) { + return apply(new SetShuffleOrder(tag, shuffleOrder)); + } + + /** + * Schedules a shuffle setting action to be executed. * * @return The builder, for convenience. */ @@ -394,18 +494,19 @@ public Builder sendMessage( * @return The builder, for convenience. */ public Builder waitForTimelineChanged() { - return apply(new WaitForTimelineChanged(tag, /* expectedTimeline= */ null)); + return apply(new WaitForTimelineChanged(tag)); } /** * Schedules a delay until the timeline changed to a specified expected timeline. * - * @param expectedTimeline The expected timeline to wait for. If null, wait for any timeline - * change. + * @param expectedTimeline The expected timeline. + * @param expectedReason The expected reason of the timeline change. * @return The builder, for convenience. */ - public Builder waitForTimelineChanged(Timeline expectedTimeline) { - return apply(new WaitForTimelineChanged(tag, expectedTimeline)); + public Builder waitForTimelineChanged( + Timeline expectedTimeline, @Player.TimelineChangeReason int expectedReason) { + return apply(new WaitForTimelineChanged(tag, expectedTimeline, expectedReason)); } /** @@ -447,6 +548,16 @@ public Builder waitForIsLoading(boolean targetIsLoading) { return apply(new WaitForIsLoading(tag, targetIsLoading)); } + /** + * Schedules a delay until a message arrives at the {@link PlayerMessage.Target}. + * + * @param playerTarget The target to observe. + * @return The builder, for convenience. + */ + public Builder waitForMessage(PlayerTarget playerTarget) { + return apply(new WaitForMessage(tag, playerTarget)); + } + /** * Schedules a {@link Runnable}. * @@ -484,10 +595,28 @@ private Builder appendActionNode(ActionNode actionNode) { /** * Provides a wrapper for a {@link Target} which has access to the player when handling messages. * Can be used with {@link Builder#sendMessage(Target, long)}. + * + *

The target can be passed to {@link ActionSchedule.Builder#waitForMessage(PlayerTarget)} to + * wait for a message to arrive at the target. */ public abstract static class PlayerTarget implements Target { + /** Callback to be called when message arrives. */ + public interface Callback { + /** Notifies about the arrival of the message. */ + void onMessageArrived(); + } + private SimpleExoPlayer player; + private boolean hasArrived; + private Callback callback; + + public void setCallback(Callback callback) { + this.callback = callback; + if (hasArrived) { + callback.onMessageArrived(); + } + } /** Handles the message send to the component and additionally provides access to the player. */ public abstract void handleMessage( @@ -499,9 +628,12 @@ public abstract void handleMessage( } @Override - public final void handleMessage(int messageType, @Nullable Object message) - throws ExoPlaybackException { + public final void handleMessage(int messageType, @Nullable Object message) { handleMessage(player, messageType, message); + if (callback != null) { + hasArrived = true; + callback.onMessageArrived(); + } } } 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 4416ab0ef3f..28cf8bab66d 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 @@ -16,11 +16,14 @@ package com.google.android.exoplayer2.testutil; import static com.google.common.truth.Truth.assertThat; +import static junit.framework.TestCase.assertFalse; +import static junit.framework.TestCase.assertTrue; import android.content.Context; import android.os.HandlerThread; import android.os.Looper; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; @@ -43,6 +46,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -87,8 +91,8 @@ public static final class Builder { private Clock clock; private Timeline timeline; + private List mediaSources; private Object manifest; - private MediaSource mediaSource; private DefaultTrackSelector trackSelector; private LoadControl loadControl; private BandwidthMeter bandwidthMeter; @@ -99,19 +103,31 @@ public static final class Builder { private Player.EventListener eventListener; private AnalyticsListener analyticsListener; private Integer expectedPlayerEndedCount; + private boolean useLazyPreparation; + private int initialWindowIndex; + private long initialPositionMs; + private boolean skipSettingMediaSources; + + public Builder() { + mediaSources = new ArrayList<>(); + initialWindowIndex = C.INDEX_UNSET; + initialPositionMs = C.TIME_UNSET; + } /** * Sets a {@link Timeline} to be used by a {@link FakeMediaSource} in the test runner. The * default value is a seekable, non-dynamic {@link FakeTimeline} with a duration of {@link * FakeTimeline.TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US}. Setting the timeline is - * not allowed after a call to {@link #setMediaSource(MediaSource)}. + * not allowed after a call to {@link #setMediaSources(MediaSource...)} or {@link + * #skipSettingMediaSources()}. * * @param timeline A {@link Timeline} to be used by a {@link FakeMediaSource} in the test * runner. * @return This builder. */ public Builder setTimeline(Timeline timeline) { - assertThat(mediaSource).isNull(); + assertThat(mediaSources).isEmpty(); + assertFalse(skipSettingMediaSources); this.timeline = timeline; return this; } @@ -119,30 +135,73 @@ public Builder setTimeline(Timeline timeline) { /** * Sets a manifest to be used by a {@link FakeMediaSource} in the test runner. The default value * is null. Setting the manifest is not allowed after a call to {@link - * #setMediaSource(MediaSource)}. + * #setMediaSources(MediaSource...)} or {@link #skipSettingMediaSources()}. * * @param manifest A manifest to be used by a {@link FakeMediaSource} in the test runner. * @return This builder. */ public Builder setManifest(Object manifest) { - assertThat(mediaSource).isNull(); + assertThat(mediaSources).isEmpty(); + assertFalse(skipSettingMediaSources); this.manifest = manifest; return this; } /** - * Sets a {@link MediaSource} to be used by the test runner. The default value is a {@link + * Seeks before setting the media sources and preparing the player. + * + * @param windowIndex The window index to seek to. + * @param positionMs The position in milliseconds to seek to. + * @return This builder. + */ + public Builder initialSeek(int windowIndex, long positionMs) { + this.initialWindowIndex = windowIndex; + this.initialPositionMs = positionMs; + return this; + } + + /** + * Sets the {@link MediaSource}s to be used by the test runner. The default value is a {@link * FakeMediaSource} with the timeline and manifest provided by {@link #setTimeline(Timeline)} - * and {@link #setManifest(Object)}. Setting the media source is not allowed after calls to - * {@link #setTimeline(Timeline)} and/or {@link #setManifest(Object)}. + * and {@link #setManifest(Object)}. Setting media sources is not allowed after calls to {@link + * #skipSettingMediaSources()}, {@link #setTimeline(Timeline)} and/or {@link + * #setManifest(Object)}. + * + * @param mediaSources The {@link MediaSource}s to be used by the test runner. + * @return This builder. + */ + public Builder setMediaSources(MediaSource... mediaSources) { + assertThat(timeline).isNull(); + assertThat(manifest).isNull(); + assertFalse(skipSettingMediaSources); + this.mediaSources = Arrays.asList(mediaSources); + return this; + } + + /** + * Skips calling {@link com.google.android.exoplayer2.ExoPlayer#setMediaSources(List)} before + * preparing. Calling this method is not allowed after calls to {@link + * #setMediaSources(MediaSource...)}, {@link #setTimeline(Timeline)} and/or {@link + * #setManifest(Object)}. * - * @param mediaSource A {@link MediaSource} to be used by the test runner. * @return This builder. */ - public Builder setMediaSource(MediaSource mediaSource) { + public Builder skipSettingMediaSources() { assertThat(timeline).isNull(); assertThat(manifest).isNull(); - this.mediaSource = mediaSource; + assertTrue(mediaSources.isEmpty()); + skipSettingMediaSources = true; + return this; + } + + /** + * Sets whether to use lazy preparation. + * + * @param useLazyPreparation Whether to use lazy preparation. + * @return This builder. + */ + public Builder setUseLazyPreparation(boolean useLazyPreparation) { + this.useLazyPreparation = useLazyPreparation; return this; } @@ -186,7 +245,7 @@ public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) { * Sets a list of {@link Format}s to be used by a {@link FakeMediaSource} to create media * periods and for setting up a {@link FakeRenderer}. The default value is a single {@link * #VIDEO_FORMAT}. Note that this parameter doesn't have any influence if both a media source - * with {@link #setMediaSource(MediaSource)} and renderers with {@link + * with {@link #setMediaSources(MediaSource...)} and renderers with {@link * #setRenderers(Renderer...)} or {@link #setRenderersFactory(RenderersFactory)} are set. * * @param supportedFormats A list of supported {@link Format}s. @@ -240,7 +299,7 @@ public Builder setClock(Clock clock) { /** * Sets an {@link ActionSchedule} to be run by the test runner. The first action will be - * executed immediately before {@link SimpleExoPlayer#prepare(MediaSource)}. + * executed immediately before {@link SimpleExoPlayer#prepare()}. * * @param actionSchedule An {@link ActionSchedule} to be used by the test runner. * @return This builder. @@ -321,11 +380,11 @@ public ExoPlayerTestRunner build(Context context) { if (clock == null) { clock = new AutoAdvancingFakeClock(); } - if (mediaSource == null) { + if (mediaSources.isEmpty() && !skipSettingMediaSources) { if (timeline == null) { timeline = new FakeTimeline(/* windowCount= */ 1, manifest); } - mediaSource = new FakeMediaSource(timeline, supportedFormats); + mediaSources.add(new FakeMediaSource(timeline, supportedFormats)); } if (expectedPlayerEndedCount == null) { expectedPlayerEndedCount = 1; @@ -333,7 +392,11 @@ public ExoPlayerTestRunner build(Context context) { return new ExoPlayerTestRunner( context, clock, - mediaSource, + initialWindowIndex, + initialPositionMs, + mediaSources, + skipSettingMediaSources, + useLazyPreparation, renderersFactory, trackSelector, loadControl, @@ -347,7 +410,9 @@ public ExoPlayerTestRunner build(Context context) { private final Context context; private final Clock clock; - private final MediaSource mediaSource; + private final int initialWindowIndex; + private final long initialPositionMs; + private final List mediaSources; private final RenderersFactory renderersFactory; private final DefaultTrackSelector trackSelector; private final LoadControl loadControl; @@ -364,6 +429,9 @@ public ExoPlayerTestRunner build(Context context) { private final ArrayList timelineChangeReasons; private final ArrayList periodIndices; private final ArrayList discontinuityReasons; + private final ArrayList playbackStates; + private final boolean skipSettingMediaSources; + private final boolean useLazyPreparation; private SimpleExoPlayer player; private Exception exception; @@ -373,7 +441,11 @@ public ExoPlayerTestRunner build(Context context) { private ExoPlayerTestRunner( Context context, Clock clock, - MediaSource mediaSource, + int initialWindowIndex, + long initialPositionMs, + List mediaSources, + boolean skipSettingMediaSources, + boolean useLazyPreparation, RenderersFactory renderersFactory, DefaultTrackSelector trackSelector, LoadControl loadControl, @@ -384,7 +456,11 @@ private ExoPlayerTestRunner( int expectedPlayerEndedCount) { this.context = context; this.clock = clock; - this.mediaSource = mediaSource; + this.initialWindowIndex = initialWindowIndex; + this.initialPositionMs = initialPositionMs; + this.mediaSources = mediaSources; + this.skipSettingMediaSources = skipSettingMediaSources; + this.useLazyPreparation = useLazyPreparation; this.renderersFactory = renderersFactory; this.trackSelector = trackSelector; this.loadControl = loadControl; @@ -396,6 +472,7 @@ private ExoPlayerTestRunner( this.timelineChangeReasons = new ArrayList<>(); this.periodIndices = new ArrayList<>(); this.discontinuityReasons = new ArrayList<>(); + this.playbackStates = new ArrayList<>(); this.endedCountDownLatch = new CountDownLatch(expectedPlayerEndedCount); this.actionScheduleFinishedCountDownLatch = new CountDownLatch(actionSchedule != null ? 1 : 0); this.playerThread = new HandlerThread("ExoPlayerTest thread"); @@ -434,6 +511,7 @@ public ExoPlayerTestRunner start(boolean doPrepare) { .setBandwidthMeter(bandwidthMeter) .setAnalyticsCollector(new AnalyticsCollector(clock)) .setClock(clock) + .setUseLazyPreparation(useLazyPreparation) .setLooper(Looper.myLooper()) .build(); player.addListener(ExoPlayerTestRunner.this); @@ -447,8 +525,15 @@ public ExoPlayerTestRunner start(boolean doPrepare) { if (actionSchedule != null) { actionSchedule.start(player, trackSelector, null, handler, ExoPlayerTestRunner.this); } - player.setMediaSource(mediaSource); - player.prepare(); + if (initialWindowIndex != C.INDEX_UNSET) { + player.seekTo(initialWindowIndex, initialPositionMs); + } + if (!skipSettingMediaSources) { + player.setMediaSources(mediaSources, /* resetPosition= */ false); + } + if (doPrepare) { + player.prepare(); + } } catch (Exception e) { handleException(e); } @@ -500,12 +585,17 @@ public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs) /** * Asserts that the timelines reported by {@link Player.EventListener#onTimelineChanged(Timeline, - * int)} are equal to the provided timelines. + * int)} are the same to the provided timelines. This assert differs from testing equality by not + * comparing period ids which may be different due to id mapping of child source period ids. * * @param timelines A list of expected {@link Timeline}s. */ - public void assertTimelinesEqual(Timeline... timelines) { - assertThat(this.timelines).containsExactlyElementsIn(Arrays.asList(timelines)).inOrder(); + public void assertTimelinesSame(Timeline... timelines) { + assertThat(this.timelines).hasSize(timelines.length); + for (int i = 0; i < timelines.length; i++) { + assertThat(new NoUidTimeline(timelines[i])) + .isEqualTo(new NoUidTimeline(this.timelines.get(i))); + } } /** @@ -517,6 +607,15 @@ public void assertTimelineChangeReasonsEqual(Integer... reasons) { assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder(); } + /** + * Asserts that the playback states reported by {@link + * Player.EventListener#onPlayerStateChanged(boolean, int)} are equal to the provided playback + * states. + */ + public void assertPlaybackStatesEqual(Integer... states) { + assertThat(playbackStates).containsExactlyElementsIn(Arrays.asList(states)).inOrder(); + } + /** * Asserts that the last track group array reported by {@link * Player.EventListener#onTracksChanged(TrackGroupArray, TrackSelectionArray)} is equal to the @@ -592,10 +691,12 @@ private void handleException(Exception exception) { @Override public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { - timelines.add(timeline); timelineChangeReasons.add(reason); - if (reason == Player.TIMELINE_CHANGE_REASON_PREPARED) { - periodIndices.add(player.getCurrentPeriodIndex()); + timelines.add(timeline); + int currentIndex = player.getCurrentPeriodIndex(); + if (periodIndices.isEmpty() || periodIndices.get(periodIndices.size() - 1) != currentIndex) { + // Ignore timeline changes that do not change the period index. + periodIndices.add(currentIndex); } } @@ -606,6 +707,7 @@ public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray tra @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + playbackStates.add(playbackState); playerWasPrepared |= playbackState != Player.STATE_IDLE; if (playbackState == Player.STATE_ENDED || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java index b347ecc0b79..e4bc539c473 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaSource.java @@ -26,6 +26,7 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.source.BaseMediaSource; +import com.google.android.exoplayer2.source.ForwardingTimeline; import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaPeriod; @@ -49,6 +50,22 @@ */ public class FakeMediaSource extends BaseMediaSource { + /** A forwarding timeline to provide an initial timeline for fake multi window sources. */ + public static class InitialTimeline extends ForwardingTimeline { + + public InitialTimeline(Timeline timeline) { + super(timeline); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + Window childWindow = timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + childWindow.isDynamic = true; + childWindow.isSeekable = false; + return childWindow; + } + } + private static final DataSpec FAKE_DATA_SPEC = new DataSpec(Uri.parse("http://manifest.uri")); private static final int MANIFEST_LOAD_BYTES = 100; @@ -92,6 +109,19 @@ public Object getTag() { return hasTimeline ? timeline.getWindow(0, new Timeline.Window()).tag : null; } + @Nullable + @Override + public Timeline getInitialTimeline() { + return timeline == null || timeline == Timeline.EMPTY || timeline.getWindowCount() == 1 + ? null + : new InitialTimeline(timeline); + } + + @Override + public boolean isSingleWindow() { + return timeline == null || timeline == Timeline.EMPTY || timeline.getWindowCount() == 1; + } + @Override public synchronized void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { assertThat(preparedSource).isFalse(); @@ -249,5 +279,4 @@ private static TrackGroupArray buildTrackGroupArray(Format... formats) { } return new TrackGroupArray(trackGroups); } - } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java index 81efb3ba78b..b1851106dc8 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -25,8 +25,10 @@ import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import java.util.List; /** * An abstract {@link ExoPlayer} implementation that throws {@link UnsupportedOperationException} @@ -91,21 +93,36 @@ public ExoPlaybackException getPlaybackError() { throw new UnsupportedOperationException(); } + /** @deprecated Use {@link #prepare()} instead. */ + @Deprecated @Override public void retry() { throw new UnsupportedOperationException(); } + /** + * @deprecated Use {@link #setMediaSource(MediaSource)} and {@link ExoPlayer#prepare()} instead. + */ + @Deprecated @Override public void prepare() { throw new UnsupportedOperationException(); } + /** + * @deprecated Use {@link #setMediaSource(MediaSource)} and {@link ExoPlayer#prepare()} instead. + */ + @Deprecated @Override public void prepare(MediaSource mediaSource) { throw new UnsupportedOperationException(); } + /** + * @deprecated Use {@link #setMediaSource(MediaSource, boolean)} and {@link ExoPlayer#prepare()} + * instead. + */ + @Deprecated @Override public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { throw new UnsupportedOperationException(); @@ -121,6 +138,72 @@ public void setMediaSource(MediaSource mediaSource, long startPositionMs) { throw new UnsupportedOperationException(); } + @Override + public void setMediaSource(MediaSource mediaSource, boolean resetPosition) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaSources(List mediaSources) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaSources(List mediaSources, boolean resetPosition) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaSources( + List mediaSources, int startWindowIndex, long startPositionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaSource(MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaSource(int index, MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaSources(List mediaSources) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaSources(int index, List mediaSources) { + throw new UnsupportedOperationException(); + } + + @Override + public void moveMediaItem(int currentIndex, int newIndex) { + throw new UnsupportedOperationException(); + } + + @Override + public void moveMediaItems(int fromIndex, int toIndex, int newIndex) { + throw new UnsupportedOperationException(); + } + + @Override + public MediaSource removeMediaItem(int index) { + throw new UnsupportedOperationException(); + } + + @Override + public void removeMediaItems(int fromIndex, int toIndex) { + throw new UnsupportedOperationException(); + } + + @Override + public void clearMediaItems() { + throw new UnsupportedOperationException(); + } + @Override public void setPlayWhenReady(boolean playWhenReady) { throw new UnsupportedOperationException(); @@ -141,6 +224,11 @@ public int getRepeatMode() { throw new UnsupportedOperationException(); } + @Override + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + throw new UnsupportedOperationException(); + } + @Override public void setShuffleModeEnabled(boolean shuffleModeEnabled) { throw new UnsupportedOperationException();