From 3b22db33ba944df6829b1eff328efb0cd25e1678 Mon Sep 17 00:00:00 2001 From: bachinger Date: Tue, 17 Sep 2019 17:20:11 +0100 Subject: [PATCH] add top-level playlist API to ExoPlayer Public design doc: https://docs.google.com/document/d/11h0S91KI5TB3NNZUtsCzg0S7r6nyTnF_tDZZAtmY93g Issue: #6161 PiperOrigin-RevId: 269584512 --- .../exoplayer2/demo/PlayerActivity.java | 54 +- .../exoplayer2/ext/cast/CastPlayer.java | 11 +- .../exoplayer2/ext/ima/FakePlayer.java | 15 +- .../exoplayer2/ext/ima/ImaAdsLoaderTest.java | 4 +- .../AbstractConcatenatedTimeline.java | 12 +- .../google/android/exoplayer2/ExoPlayer.java | 158 +++- .../android/exoplayer2/ExoPlayerFactory.java | 10 +- .../android/exoplayer2/ExoPlayerImpl.java | 307 +++++-- .../exoplayer2/ExoPlayerImplInternal.java | 364 +++++--- .../android/exoplayer2/MediaPeriodHolder.java | 24 +- .../android/exoplayer2/MediaPeriodQueue.java | 7 +- .../com/google/android/exoplayer2/Player.java | 29 +- .../google/android/exoplayer2/Playlist.java | 698 ++++++++++++++ .../android/exoplayer2/SimpleExoPlayer.java | 179 +++- .../google/android/exoplayer2/Timeline.java | 69 ++ .../analytics/AnalyticsCollector.java | 17 +- .../source/ConcatenatingMediaSource.java | 1 + .../exoplayer2/source/LoopingMediaSource.java | 1 + .../exoplayer2/source/MaskingMediaSource.java | 12 +- .../android/exoplayer2/util/EventLogger.java | 10 +- .../google/android/exoplayer2/util/Util.java | 37 + .../android/exoplayer2/ExoPlayerTest.java | 851 +++++++++++++----- .../exoplayer2/MediaPeriodQueueTest.java | 111 +-- .../android/exoplayer2/PlaylistTest.java | 510 +++++++++++ .../android/exoplayer2/TimelineTest.java | 140 +++ .../analytics/AnalyticsCollectorTest.java | 99 +- .../android/exoplayer2/testutil/Action.java | 348 ++++--- .../exoplayer2/testutil/ActionSchedule.java | 119 ++- .../exoplayer2/testutil/ExoHostedTest.java | 3 +- .../testutil/ExoPlayerTestRunner.java | 100 +- .../exoplayer2/testutil/StubExoPlayer.java | 83 ++ .../android/exoplayer2/testutil/TestUtil.java | 58 ++ 32 files changed, 3602 insertions(+), 839 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/{source => }/AbstractConcatenatedTimeline.java (97%) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/Playlist.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java 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 347f49e27c..a9d1db64ad 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 @@ -49,7 +49,6 @@ import com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryExcep 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.ProgressiveMediaSource; @@ -79,6 +78,7 @@ import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; import java.util.ArrayList; +import java.util.List; import java.util.UUID; /** An activity that plays media using {@link SimpleExoPlayer}. */ @@ -141,7 +141,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; @@ -343,8 +343,8 @@ public class PlayerActivity extends AppCompatActivity Intent intent = getIntent(); releaseMediaDrms(); - mediaSource = createTopLevelMediaSource(intent); - if (mediaSource == null) { + mediaSources = createTopLevelMediaSources(intent); + if (mediaSources.isEmpty()) { return; } @@ -388,12 +388,12 @@ public class PlayerActivity extends AppCompatActivity if (haveStartPosition) { player.seekTo(startWindow, startPosition); } - player.prepare(mediaSource, !haveStartPosition, false); + player.setMediaItems(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)) { @@ -421,34 +421,30 @@ public class PlayerActivity extends AppCompatActivity } } - MediaSource[] mediaSources = new MediaSource[samples.length]; - for (int i = 0; i < samples.length; i++) { - mediaSources[i] = createLeafMediaSource(samples[i]); + List mediaSources = new ArrayList<>(); + for (UriSample sample : samples) { + mediaSources.add(createLeafMediaSource(sample)); } - 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); - } 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); - } + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; } + MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri); + if (adsMediaSource != null) { + mediaSources.set(0, adsMediaSource); + } else { + 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) { @@ -548,7 +544,7 @@ public class PlayerActivity extends AppCompatActivity 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 b82e736c86..91a4c86cf2 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 @@ -106,7 +106,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. @@ -168,7 +167,6 @@ public final class CastPlayer extends BasePlayer { 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); } @@ -594,12 +592,13 @@ public final class CastPlayer extends BasePlayer { private void maybeUpdateTimelineAndNotify() { 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/FakePlayer.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java index a9572b7a8d..a6a725ee9e 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java @@ -30,7 +30,6 @@ import java.util.ArrayList; private final Timeline.Period period; private final Timeline timeline; - private boolean prepared; @Player.State private int state; private boolean playWhenReady; private long position; @@ -47,13 +46,17 @@ import java.util.ArrayList; timeline = Timeline.EMPTY; } - /** Sets the timeline on this fake player, which notifies listeners with the changed timeline. */ - public void updateTimeline(Timeline timeline) { + /** + * Sets the timeline on this fake player, which notifies listeners with the changed timeline and + * the given timeline change reason. + * + * @param timeline The new timeline. + * @param timelineChangeReason The reason for the timeline change. + */ + public void updateTimeline(Timeline timeline, @TimelineChangeReason int timelineChangeReason) { for (Player.EventListener listener : listeners) { - listener.onTimelineChanged( - timeline, prepared ? TIMELINE_CHANGE_REASON_DYNAMIC : TIMELINE_CHANGE_REASON_PREPARED); + listener.onTimelineChanged(timeline, timelineChangeReason); } - prepared = true; } /** 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 2995df4ab4..a5c6b00619 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 @@ -285,7 +285,9 @@ public class ImaAdsLoaderTest { public void onAdPlaybackState(AdPlaybackState adPlaybackState) { adPlaybackState = adPlaybackState.withAdDurationsUs(adDurationsUs); this.adPlaybackState = adPlaybackState; - fakeExoPlayer.updateTimeline(new SinglePeriodAdTimeline(contentTimeline, adPlaybackState)); + fakeExoPlayer.updateTimeline( + new SinglePeriodAdTimeline(contentTimeline, adPlaybackState), + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Override diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java similarity index 97% rename from library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java rename to library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java index 703bb7e3a8..a307e4b35d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java @@ -13,18 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.google.android.exoplayer2.source; +package com.google.android.exoplayer2; import android.util.Pair; -import com.google.android.exoplayer2.C; -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.util.Assertions; -/** - * Abstract base class for the concatenation of one or more {@link Timeline}s. - */ -/* package */ abstract class AbstractConcatenatedTimeline extends Timeline { +/** Abstract base class for the concatenation of one or more {@link Timeline}s. */ +public abstract class AbstractConcatenatedTimeline extends Timeline { private final int childCount; private final ShuffleOrder shuffleOrder; 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 2bed5d6f8b..4418549c8b 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.LoopingMediaSource; 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.Assertions; 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 @@ public interface ExoPlayer extends Player { private LoadControl loadControl; private BandwidthMeter bandwidthMeter; private Looper looper; - private AnalyticsCollector analyticsCollector; + @Nullable private AnalyticsCollector analyticsCollector; private boolean useLazyPreparation; private boolean buildCalled; @@ -170,7 +172,7 @@ public interface ExoPlayer extends Player { new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), Util.getLooper(), - new AnalyticsCollector(Clock.DEFAULT), + /* analyticsCollector= */ null, /* useLazyPreparation= */ true, Clock.DEFAULT); } @@ -197,7 +199,7 @@ public interface ExoPlayer extends Player { LoadControl loadControl, BandwidthMeter bandwidthMeter, Looper looper, - AnalyticsCollector analyticsCollector, + @Nullable AnalyticsCollector analyticsCollector, boolean useLazyPreparation, Clock clock) { Assertions.checkArgument(renderers.length > 0); @@ -318,38 +320,156 @@ public interface ExoPlayer extends Player { Assertions.checkState(!buildCalled); buildCalled = true; return new ExoPlayerImpl( - renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + renderers, + trackSelector, + loadControl, + bandwidthMeter, + analyticsCollector, + useLazyPreparation, + clock, + looper); } } /** 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 to play the provided {@link MediaSource}. Equivalent to - * {@code prepare(mediaSource, true, true)}. - */ + /** @deprecated Use {@link #setMediaItem(MediaSource)} and {@link #prepare()} instead. */ + @Deprecated void prepare(MediaSource mediaSource); + /** @deprecated Use {@link #setMediaItems(List, int, long)} and {@link #prepare()} instead. */ + @Deprecated + void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState); + + /** Prepares the player. */ + void prepare(); + /** - * Prepares the player to play the provided {@link MediaSource}, optionally resetting the playback - * position the default position in the first {@link Timeline.Window}. + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. * - * @param mediaSource The {@link MediaSource} to play. + * @param mediaItems The new {@link MediaSource MediaSources}. + */ + void setMediaItems(List mediaItems); + + /** + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. + * + * @param mediaItems 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()}. - * @param resetState Whether the timeline, manifest, tracks and track selections should be reset. - * Should be true unless the player is being prepared to play the same media as it was playing - * previously (e.g. if playback failed and is being retried). */ - void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState); + void setMediaItems(List mediaItems, boolean resetPosition); + + /** + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. + * + * @param mediaItems 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 setMediaItems(List mediaItems, int startWindowIndex, long startPositionMs); + + /** + * Clears the playlist and adds the specified {@link MediaSource}. + * + * @param mediaItem The new {@link MediaSource}. + */ + void setMediaItem(MediaSource mediaItem); + + /** + * Clears the playlist and adds the specified {@link MediaSource}. + * + * @param mediaItem The new {@link MediaSource}. + * @param startPositionMs The position in milliseconds to start playback from. + */ + void setMediaItem(MediaSource mediaItem, long startPositionMs); + + /** + * Adds a media item to the end of the playlist. + * + * @param mediaSource The {@link MediaSource} to add. + */ + void addMediaItem(MediaSource mediaSource); + + /** + * Adds a media item at the given index of the playlist. + * + * @param index The index at which to add the item. + * @param mediaSource The {@link MediaSource} to add. + */ + void addMediaItem(int index, MediaSource mediaSource); + + /** + * Adds a list of media items to the end of the playlist. + * + * @param mediaSources The {@link MediaSource MediaSources} to add. + */ + void addMediaItems(List mediaSources); + + /** + * Adds a list of media items at the given index of the playlist. + * + * @param index The index at which to add the media items. + * @param mediaSources The {@link MediaSource MediaSources} to add. + */ + void addMediaItems(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 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 efe351c70a..b900491b1d 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 @@ -296,6 +296,7 @@ public final class ExoPlayerFactory { drmSessionManager, bandwidthMeter, analyticsCollector, + /* useLazyPreparation= */ true, Clock.DEFAULT, looper); } @@ -344,6 +345,13 @@ public final class ExoPlayerFactory { 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 d87bdc7d74..0401f7d595 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.os.Message; 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.Clock; 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; /** @@ -61,19 +66,20 @@ import java.util.concurrent.CopyOnWriteArrayList; private final CopyOnWriteArrayList listeners; private final Timeline.Period period; private final ArrayDeque pendingListenerNotifications; + private final List mediaSourceHolders; + private final boolean useLazyPreparation; - 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; @@ -90,6 +96,10 @@ import java.util.concurrent.CopyOnWriteArrayList; * @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. @@ -100,6 +110,8 @@ import java.util.concurrent.CopyOnWriteArrayList; TrackSelector trackSelector, LoadControl loadControl, BandwidthMeter bandwidthMeter, + @Nullable AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" @@ -107,10 +119,13 @@ import java.util.concurrent.CopyOnWriteArrayList; 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], @@ -129,6 +144,9 @@ import java.util.concurrent.CopyOnWriteArrayList; }; playbackInfo = PlaybackInfo.createDummy(/* startPositionUs= */ 0, emptyTrackSelectorResult); pendingListenerNotifications = new ArrayDeque<>(); + if (analyticsCollector != null) { + analyticsCollector.setPlayer(this); + } internalPlayer = new ExoPlayerImplInternal( renderers, @@ -139,6 +157,7 @@ import java.util.concurrent.CopyOnWriteArrayList; playWhenReady, repeatMode, shuffleModeEnabled, + analyticsCollector, eventHandler, clock); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); @@ -212,41 +231,177 @@ import java.util.concurrent.CopyOnWriteArrayList; } @Override + @Deprecated 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; } - } - - @Override - public void prepare(MediaSource mediaSource) { - prepare(mediaSource, /* resetPosition= */ true, /* resetState= */ true); - } - - @Override - public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { - this.mediaSource = mediaSource; PlaybackInfo playbackInfo = getResetPlaybackInfo( - resetPosition, - resetState, + /* clearPlaylist= */ false, /* 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); + internalPlayer.prepare(); updatePlaybackInfo( playbackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, - TIMELINE_CHANGE_REASON_RESET, + /* ignored */ TIMELINE_CHANGE_REASON_SOURCE_UPDATE, /* seekProcessed= */ false); } + @Override + @Deprecated + public void prepare(MediaSource mediaSource) { + setMediaItem(mediaSource); + prepare(); + } + + @Override + @Deprecated + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + setMediaItem( + mediaSource, /* startPositionMs= */ resetPosition ? C.TIME_UNSET : getCurrentPosition()); + prepare(); + } + + @Override + public void setMediaItem(MediaSource mediaItem) { + setMediaItems(Collections.singletonList(mediaItem)); + } + + @Override + public void setMediaItem(MediaSource mediaItem, long startPositionMs) { + setMediaItems(Collections.singletonList(mediaItem), /* startWindowIndex= */ 0, startPositionMs); + } + + @Override + public void setMediaItems(List mediaItems) { + setMediaItems( + mediaItems, /* startWindowIndex= */ C.INDEX_UNSET, /* startPositionMs */ C.TIME_UNSET); + } + + @Override + public void setMediaItems(List mediaItems, boolean resetPosition) { + setMediaItems( + mediaItems, + /* startWindowIndex= */ resetPosition ? C.INDEX_UNSET : getCurrentWindowIndex(), + /* startPositionMs= */ resetPosition ? C.TIME_UNSET : getCurrentPosition()); + } + + @Override + public void setMediaItems( + List mediaItems, int startWindowIndex, long startPositionMs) { + pendingOperationAcks++; + if (!mediaSourceHolders.isEmpty()) { + removeMediaSourceHolders( + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + } + List holders = addMediaSourceHolders(/* index= */ 0, mediaItems); + maskTimeline(); + internalPlayer.setMediaItems( + holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder); + notifyListeners( + listener -> + listener.onTimelineChanged( + playbackInfo.timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + @Override + public void addMediaItem(MediaSource mediaSource) { + addMediaItems(Collections.singletonList(mediaSource)); + } + + @Override + public void addMediaItem(int index, MediaSource mediaSource) { + addMediaItems(index, Collections.singletonList(mediaSource)); + } + + @Override + public void addMediaItems(List mediaSources) { + addMediaItems(/* index= */ mediaSourceHolders.size(), mediaSources); + } + + @Override + public void addMediaItems(int index, List mediaSources) { + Assertions.checkArgument(index >= 0); + pendingOperationAcks++; + List holders = addMediaSourceHolders(index, mediaSources); + maskTimeline(); + internalPlayer.addMediaItems(index, holders, shuffleOrder); + notifyListeners( + listener -> + listener.onTimelineChanged( + playbackInfo.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); + pendingOperationAcks++; + newFromIndex = Math.min(newFromIndex, mediaSourceHolders.size() - (toIndex - fromIndex)); + Playlist.moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); + maskTimeline(); + internalPlayer.moveMediaItems(fromIndex, toIndex, newFromIndex, shuffleOrder); + notifyListeners( + listener -> + listener.onTimelineChanged( + playbackInfo.timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + @Override + public void clearMediaItems() { + if (mediaSourceHolders.isEmpty()) { + return; + } + removeMediaItemsInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); + } + + @Override + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + pendingOperationAcks++; + this.shuffleOrder = shuffleOrder; + maskTimeline(); + internalPlayer.setShuffleOrder(shuffleOrder); + notifyListeners( + listener -> + listener.onTimelineChanged( + playbackInfo.timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + @Override public void setPlayWhenReady(boolean playWhenReady) { setPlayWhenReady(playWhenReady, PLAYBACK_SUPPRESSION_REASON_NONE); @@ -403,13 +558,9 @@ import java.util.concurrent.CopyOnWriteArrayList; @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 @@ -422,7 +573,7 @@ import java.util.concurrent.CopyOnWriteArrayList; playbackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, - TIMELINE_CHANGE_REASON_RESET, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* seekProcessed= */ false); } @@ -431,13 +582,11 @@ import java.util.concurrent.CopyOnWriteArrayList; Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " [" + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); - mediaSource = null; internalPlayer.release(); eventHandler.removeCallbacksAndMessages(null); playbackInfo = getResetPlaybackInfo( - /* resetPosition= */ false, - /* resetState= */ false, + /* clearPlaylist= */ false, /* resetError= */ false, /* playbackState= */ Player.STATE_IDLE); } @@ -585,10 +734,11 @@ import java.util.concurrent.CopyOnWriteArrayList; // 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); @@ -636,29 +786,23 @@ import java.util.concurrent.CopyOnWriteArrayList; maskingWindowIndex = 0; maskingWindowPositionMs = 0; } - @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) { + 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()); maskingWindowIndex = 0; maskingPeriodIndex = 0; maskingWindowPositionMs = 0; @@ -667,24 +811,22 @@ import java.util.concurrent.CopyOnWriteArrayList; maskingPeriodIndex = getCurrentPeriodIndex(); maskingWindowPositionMs = getCurrentPosition(); } - // Also reset period-based PlaybackInfo positions if resetting the state. - resetPosition = resetPosition || resetState; MediaPeriodId mediaPeriodId = - resetPosition + clearPlaylist ? playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period) : playbackInfo.periodId; - long startPositionUs = resetPosition ? 0 : playbackInfo.positionUs; - long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; + long startPositionUs = clearPlaylist ? 0 : playbackInfo.positionUs; + long contentPositionUs = clearPlaylist ? C.TIME_UNSET : playbackInfo.contentPositionUs; return new PlaybackInfo( - resetState ? Timeline.EMPTY : playbackInfo.timeline, + clearPlaylist ? Timeline.EMPTY : playbackInfo.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, @@ -694,8 +836,8 @@ import java.util.concurrent.CopyOnWriteArrayList; 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. @@ -716,6 +858,54 @@ import java.util.concurrent.CopyOnWriteArrayList; /* isPlayingChanged= */ previousIsPlaying != isPlaying)); } + 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()); + pendingOperationAcks++; + List mediaSourceHolders = + removeMediaSourceHolders(fromIndex, /* toIndexExclusive= */ toIndex); + maskTimeline(); + internalPlayer.removeMediaItems(fromIndex, toIndex, shuffleOrder); + notifyListeners( + listener -> + listener.onTimelineChanged( + playbackInfo.timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + return mediaSourceHolders; + } + + 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 void maskTimeline() { + playbackInfo = + playbackInfo.copyWithTimeline( + mediaSourceHolders.isEmpty() + ? Timeline.EMPTY + : new Playlist.PlaylistTimeline(mediaSourceHolders, shuffleOrder)); + } + private void notifyListeners(ListenerInvocation listenerInvocation) { CopyOnWriteArrayList listenerSnapshot = new CopyOnWriteArrayList<>(listeners); notifyListeners(() -> invokeAll(listenerSnapshot, listenerInvocation)); @@ -751,7 +941,7 @@ import java.util.concurrent.CopyOnWriteArrayList; 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; @@ -785,15 +975,16 @@ import java.util.concurrent.CopyOnWriteArrayList; playbackErrorChanged = previousPlaybackInfo.playbackError != playbackInfo.playbackError && playbackInfo.playbackError != null; - timelineChanged = previousPlaybackInfo.timeline != playbackInfo.timeline; isLoadingChanged = previousPlaybackInfo.isLoading != playbackInfo.isLoading; + timelineChanged = + !Util.areTimelinesSame(previousPlaybackInfo.timeline, 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 1478a1b2bf..49de45c771 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 @@ -26,11 +26,11 @@ import androidx.annotation.NonNull; 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; @@ -45,6 +45,7 @@ import com.google.android.exoplayer2.util.Util; 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}. */ @@ -52,7 +53,7 @@ import java.util.concurrent.atomic.AtomicBoolean; implements Handler.Callback, MediaPeriod.Callback, TrackSelector.InvalidationListener, - MediaSourceCaller, + Playlist.PlaylistInfoRefreshListener, PlaybackParameterListener, PlayerMessage.Sender { @@ -71,16 +72,21 @@ import java.util.concurrent.atomic.AtomicBoolean; 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_ITEMS = 17; + private static final int MSG_ADD_MEDIA_ITEMS = 18; + private static final int MSG_MOVE_MEDIA_ITEMS = 19; + private static final int MSG_REMOVE_MEDIA_ITEMS = 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; @@ -103,12 +109,12 @@ import java.util.concurrent.atomic.AtomicBoolean; 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 @@ import java.util.concurrent.atomic.AtomicBoolean; private boolean shuffleModeEnabled; private boolean foregroundMode; - private int pendingPrepareCount; - private SeekPosition pendingInitialSeekPosition; + @Nullable private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; private int nextPendingMessageIndex; @@ -131,6 +136,7 @@ import java.util.concurrent.atomic.AtomicBoolean; boolean playWhenReady, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, + @Nullable AnalyticsCollector analyticsCollector, Handler eventHandler, Clock clock) { this.renderers = renderers; @@ -166,16 +172,18 @@ import java.util.concurrent.atomic.AtomicBoolean; // Note: The documentation for Process.THREAD_PRIORITY_AUDIO that states "Applications can // not normally change to this priority" is incorrect. - internalPlaybackThread = new HandlerThread("ExoPlayerImplInternal:Handler", - Process.THREAD_PRIORITY_AUDIO); + internalPlaybackThread = + new HandlerThread("ExoPlayerImplInternal:Handler", Process.THREAD_PRIORITY_AUDIO); internalPlaybackThread.start(); handler = clock.createHandler(internalPlaybackThread.getLooper(), this); + playlist = new Playlist(this); + if (analyticsCollector != null) { + playlist.setAnalyticsCollector(eventHandler, analyticsCollector); + } } - 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) { @@ -191,7 +199,8 @@ import java.util.concurrent.atomic.AtomicBoolean; } public void seekTo(Timeline timeline, int windowIndex, long positionUs) { - handler.obtainMessage(MSG_SEEK_TO, new SeekPosition(timeline, windowIndex, positionUs)) + handler + .obtainMessage(MSG_SEEK_TO, new SeekPosition(timeline, windowIndex, positionUs)) .sendToTarget(); } @@ -207,6 +216,62 @@ import java.util.concurrent.atomic.AtomicBoolean; handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); } + public void setMediaItems( + List mediaSources, ShuffleOrder shuffleOrder) { + setMediaItems( + mediaSources, + /* windowIndex= */ C.INDEX_UNSET, + /* positionUs= */ C.TIME_UNSET, + shuffleOrder); + } + + public void setMediaItems( + List mediaSources, + int windowIndex, + long positionUs, + ShuffleOrder shuffleOrder) { + handler + .obtainMessage( + MSG_SET_MEDIA_ITEMS, + new PlaylistUpdateMessage(mediaSources, shuffleOrder, windowIndex, positionUs)) + .sendToTarget(); + } + + public void addMediaItems( + List mediaSources, ShuffleOrder shuffleOrder) { + addMediaItems(C.INDEX_UNSET, mediaSources, shuffleOrder); + } + + public void addMediaItems( + int index, List mediaSources, ShuffleOrder shuffleOrder) { + handler + .obtainMessage( + MSG_ADD_MEDIA_ITEMS, + index, + /* ignored */ 0, + new PlaylistUpdateMessage( + mediaSources, + shuffleOrder, + /* windowIndex= */ C.INDEX_UNSET, + /* positionUs= */ C.TIME_UNSET)) + .sendToTarget(); + } + + public void removeMediaItems(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { + handler.obtainMessage(MSG_REMOVE_MEDIA_ITEMS, fromIndex, toIndex, shuffleOrder).sendToTarget(); + } + + public void moveMediaItems( + int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) { + MoveMediaItemsMessage moveMediaItemsMessage = + new MoveMediaItemsMessage(fromIndex, toIndex, newFromIndex, shuffleOrder); + handler.obtainMessage(MSG_MOVE_MEDIA_ITEMS, moveMediaItemsMessage).sendToTarget(); + } + + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + handler.obtainMessage(MSG_SET_SHUFFLE_ORDER, shuffleOrder).sendToTarget(); + } + @Override public synchronized void sendMessage(PlayerMessage message) { if (released) { @@ -263,13 +328,11 @@ import java.util.concurrent.atomic.AtomicBoolean; 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. @@ -301,14 +364,12 @@ import java.util.concurrent.atomic.AtomicBoolean; // 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); @@ -344,9 +405,6 @@ import java.util.concurrent.atomic.AtomicBoolean; 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; @@ -363,6 +421,24 @@ import java.util.concurrent.atomic.AtomicBoolean; case MSG_SEND_MESSAGE_TO_TARGET_THREAD: sendMessageToTargetThread((PlayerMessage) msg.obj); break; + case MSG_SET_MEDIA_ITEMS: + setMediaItemsInternal((PlaylistUpdateMessage) msg.obj); + break; + case MSG_ADD_MEDIA_ITEMS: + addMediaItemsInternal((PlaylistUpdateMessage) msg.obj, msg.arg1); + break; + case MSG_MOVE_MEDIA_ITEMS: + moveMediaItemsInternal((MoveMediaItemsMessage) msg.obj); + break; + case MSG_REMOVE_MEDIA_ITEMS: + 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. @@ -432,21 +508,77 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - 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; @@ -657,6 +789,7 @@ import java.util.concurrent.atomic.AtomicBoolean; long periodPositionUs; long contentPositionUs; boolean seekPositionAdjusted; + @Nullable Pair resolvedSeekPosition = resolveSeekPosition(seekPosition, /* trySubsequentPeriods= */ true); if (resolvedSeekPosition == null) { @@ -681,7 +814,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } 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) { @@ -689,9 +822,9 @@ import java.util.concurrent.atomic.AtomicBoolean; 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. @@ -836,13 +969,11 @@ import java.util.concurrent.atomic.AtomicBoolean; 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); } @@ -850,9 +981,9 @@ import java.util.concurrent.atomic.AtomicBoolean; private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* releaseMediaSource= */ true, /* resetPosition= */ true, - /* resetState= */ true, + /* releasePlaylist= */ true, + /* clearPlaylist= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -865,9 +996,9 @@ import java.util.concurrent.atomic.AtomicBoolean; 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; @@ -895,8 +1026,8 @@ import java.util.concurrent.atomic.AtomicBoolean; 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()) { @@ -907,10 +1038,10 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - queue.clear(/* keepFrontPeriodUid= */ !resetState); + queue.clear(/* keepFrontPeriodUid= */ !clearPlaylist); setIsLoading(false); - if (resetState) { - queue.setTimeline(Timeline.EMPTY); + if (clearPlaylist) { + queue.setTimeline(playlist.clear(/* shuffleOrder= */ null)); for (PendingMessageInfo pendingMessageInfo : pendingMessages) { pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); } @@ -926,24 +1057,21 @@ import java.util.concurrent.atomic.AtomicBoolean; long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; playbackInfo = new PlaybackInfo( - resetState ? Timeline.EMPTY : playbackInfo.timeline, + clearPlaylist ? Timeline.EMPTY : playbackInfo.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(); } } @@ -951,7 +1079,7 @@ import java.util.concurrent.atomic.AtomicBoolean; 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 { @@ -1266,20 +1394,11 @@ import java.util.concurrent.atomic.AtomicBoolean; } } } - 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(); @@ -1290,6 +1409,7 @@ import java.util.concurrent.atomic.AtomicBoolean; long newContentPositionUs = oldContentPositionUs; if (pendingInitialSeekPosition != null) { // Resolve initial seek position. + @Nullable Pair periodPosition = resolveSeekPosition(pendingInitialSeekPosition, /* trySubsequentPeriods= */ true); pendingInitialSeekPosition = null; @@ -1392,12 +1512,12 @@ import java.util.concurrent.atomic.AtomicBoolean; private void handleSourceInfoRefreshEndedPlayback() { 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 be used should a seek occur. resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ false, /* resetPosition= */ true, - /* resetState= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); } @@ -1417,8 +1537,9 @@ import java.util.concurrent.atomic.AtomicBoolean; 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); + oldPeriodIndex = + oldTimeline.getNextPeriodIndex( + oldPeriodIndex, period, window, repeatMode, shuffleModeEnabled); if (oldPeriodIndex == C.INDEX_UNSET) { // We've reached the end of the old timeline. break; @@ -1439,6 +1560,7 @@ import java.util.concurrent.atomic.AtomicBoolean; * @throws IllegalSeekPositionException If the window index of the seek position is outside the * bounds of the timeline. */ + @Nullable private Pair resolveSeekPosition( SeekPosition seekPosition, boolean trySubsequentPeriods) { Timeline timeline = playbackInfo.timeline; @@ -1455,8 +1577,9 @@ import java.util.concurrent.atomic.AtomicBoolean; // Map the SeekPosition to a position in the corresponding timeline. Pair periodPosition; try { - periodPosition = seekTimeline.getPeriodPosition(window, period, seekPosition.windowIndex, - seekPosition.windowPositionUs); + periodPosition = + seekTimeline.getPeriodPosition( + window, period, seekPosition.windowIndex, seekPosition.windowPositionUs); } catch (IndexOutOfBoundsException e) { // The window index of the seek position was outside the bounds of the timeline. return null; @@ -1494,13 +1617,9 @@ import java.util.concurrent.atomic.AtomicBoolean; } 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(); @@ -1520,7 +1639,7 @@ import java.util.concurrent.atomic.AtomicBoolean; rendererCapabilities, trackSelector, loadControl.getAllocator(), - mediaSource, + playlist, info, emptyTrackSelectorResult); mediaPeriodHolder.mediaPeriod.prepare(this, info.startPositionUs); @@ -1531,7 +1650,7 @@ import java.util.concurrent.atomic.AtomicBoolean; handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); } } - MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); + @Nullable MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); if (loadingPeriodHolder == null || loadingPeriodHolder.isFullyBuffered()) { setIsLoading(false); } else if (!playbackInfo.isLoading) { @@ -1540,7 +1659,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } private void maybeUpdateReadingPeriod() throws ExoPlaybackException { - MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + @Nullable MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); if (readingPeriodHolder == null) { return; } @@ -1601,7 +1720,9 @@ import java.util.concurrent.atomic.AtomicBoolean; // and it will change the provided rendererOffsetUs while the renderer is still // rendering from the playing media period. Format[] formats = getFormats(newSelection); - renderer.replaceStream(formats, readingPeriodHolder.sampleStreams[i], + renderer.replaceStream( + formats, + readingPeriodHolder.sampleStreams[i], readingPeriodHolder.getRendererOffset()); } else { // The renderer will be disabled when transitioning to playing the next period, because @@ -1819,9 +1940,13 @@ import java.util.concurrent.atomic.AtomicBoolean; // Consider as joining only if the renderer was previously disabled. boolean joining = !wasRendererEnabled && playing; // Enable the renderer. - renderer.enable(rendererConfiguration, formats, - playingPeriodHolder.sampleStreams[rendererIndex], rendererPositionUs, - joining, playingPeriodHolder.getRendererOffset()); + renderer.enable( + rendererConfiguration, + formats, + playingPeriodHolder.sampleStreams[rendererIndex], + rendererPositionUs, + joining, + playingPeriodHolder.getRendererOffset()); mediaClock.onRendererEnabled(renderer); // Start the renderer if playing. if (playing) { @@ -1943,14 +2068,38 @@ import java.util.concurrent.atomic.AtomicBoolean; } } - 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; } } @@ -1959,7 +2108,7 @@ import java.util.concurrent.atomic.AtomicBoolean; 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; @@ -1987,5 +2136,4 @@ import java.util.concurrent.atomic.AtomicBoolean; 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 850d2b7d10..5bbbcbea2a 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 androidx.annotation.Nullable; 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; * @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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** 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 @@ import org.checkerframework.checker.nullness.compatqual.NullableType; /** 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 901b7b4d94..5b39db54aa 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 android.util.Pair; 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 @@ import com.google.android.exoplayer2.util.Assertions; * @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 @@ import com.google.android.exoplayer2.util.Assertions; RendererCapabilities[] rendererCapabilities, TrackSelector trackSelector, Allocator allocator, - MediaSource mediaSource, + Playlist playlist, MediaPeriodInfo info, TrackSelectorResult emptyTrackSelectorResult) { long rendererPositionOffsetUs = @@ -158,7 +157,7 @@ import com.google.android.exoplayer2.util.Assertions; rendererPositionOffsetUs, trackSelector, allocator, - mediaSource, + playlist, info, emptyTrackSelectorResult); if (loading != null) { 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 b8be7bafa7..b9ab69c45f 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 @@ -356,7 +356,8 @@ public interface Player { * {@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, @@ -584,29 +585,17 @@ public interface Player { 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/Playlist.java b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java new file mode 100644 index 0000000000..58158239d6 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java @@ -0,0 +1,698 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import android.os.Handler; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.analytics.AnalyticsCollector; +import com.google.android.exoplayer2.source.MaskingMediaPeriod; +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.MediaSourceEventListener; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; +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.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified + * during playback. It is valid for the same {@link MediaSource} instance to be present more than + * once in the playlist. + * + *

With the exception of the constructor, all methods are called on the playback thread. + */ +/* package */ class Playlist { + + /** Listener for source events. */ + public interface PlaylistInfoRefreshListener { + + /** + * Called when the timeline of a media item has changed and a new timeline that reflects the + * current playlist state needs to be created by calling {@link #createTimeline()}. + * + *

Called on the playback thread. + */ + void onPlaylistUpdateRequested(); + } + + private final List mediaSourceHolders; + private final Map mediaSourceByMediaPeriod; + private final Map mediaSourceByUid; + private final PlaylistInfoRefreshListener playlistInfoListener; + private final MediaSourceEventListener.EventDispatcher eventDispatcher; + private final HashMap childSources; + private final Set enabledMediaSourceHolders; + + private ShuffleOrder shuffleOrder; + private boolean isPrepared; + + @Nullable private TransferListener mediaTransferListener; + + @SuppressWarnings("initialization") + public Playlist(PlaylistInfoRefreshListener listener) { + playlistInfoListener = listener; + shuffleOrder = new DefaultShuffleOrder(0); + mediaSourceByMediaPeriod = new IdentityHashMap<>(); + mediaSourceByUid = new HashMap<>(); + mediaSourceHolders = new ArrayList<>(); + eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + childSources = new HashMap<>(); + enabledMediaSourceHolders = new HashSet<>(); + } + + /** + * Sets the media sources replacing any sources previously contained in the playlist. + * + * @param holders The list of {@link MediaSourceHolder}s to set. + * @param shuffleOrder The new shuffle order. + * @return The new {@link Timeline}. + */ + public final Timeline setMediaSources( + List holders, ShuffleOrder shuffleOrder) { + removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size()); + return addMediaSources(/* index= */ this.mediaSourceHolders.size(), holders, shuffleOrder); + } + + /** + * Adds multiple {@link MediaSourceHolder}s to the playlist. + * + * @param index The index at which the new {@link MediaSourceHolder}s will be inserted. This index + * must be in the range of 0 <= index <= {@link #getSize()}. + * @param holders A list of {@link MediaSourceHolder}s to be added. + * @param shuffleOrder The new shuffle order. + * @return The new {@link Timeline}. + */ + public final Timeline addMediaSources( + int index, List holders, ShuffleOrder shuffleOrder) { + if (!holders.isEmpty()) { + this.shuffleOrder = shuffleOrder; + for (int insertionIndex = index; insertionIndex < index + holders.size(); insertionIndex++) { + MediaSourceHolder holder = holders.get(insertionIndex - index); + if (insertionIndex > 0) { + MediaSourceHolder previousHolder = mediaSourceHolders.get(insertionIndex - 1); + Timeline previousTimeline = previousHolder.mediaSource.getTimeline(); + holder.reset( + /* firstWindowInChildIndex= */ previousHolder.firstWindowIndexInChild + + previousTimeline.getWindowCount()); + } else { + holder.reset(/* firstWindowIndexInChild= */ 0); + } + Timeline newTimeline = holder.mediaSource.getTimeline(); + correctOffsets( + /* startIndex= */ insertionIndex, + /* windowOffsetUpdate= */ newTimeline.getWindowCount()); + mediaSourceHolders.add(insertionIndex, holder); + mediaSourceByUid.put(holder.uid, holder); + if (isPrepared) { + prepareChildSource(holder); + } + } + } + return createTimeline(); + } + + /** + * Removes a range of {@link MediaSourceHolder}s from the playlist, by specifying an initial index + * (included) and a final index (excluded). + * + *

Note: when specified range is empty, no actual media source is removed and no exception is + * thrown. + * + * @param fromIndex The initial range index, pointing to the first media source that will be + * removed. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param shuffleOrder The new shuffle order. + * @return The new {@link Timeline}. + * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} + */ + public final Timeline removeMediaSourceRange( + int fromIndex, int toIndex, ShuffleOrder shuffleOrder) { + Assertions.checkArgument(fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize()); + this.shuffleOrder = shuffleOrder; + removeMediaSourcesInternal(fromIndex, toIndex); + return createTimeline(); + } + + /** + * Moves an existing media source within the playlist. + * + * @param currentIndex The current index of the media source in the playlist. This index must be + * in the range of 0 <= index < {@link #getSize()}. + * @param newIndex The target index of the media source in the playlist. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @param shuffleOrder The new shuffle order. + * @return The new {@link Timeline}. + * @throws IllegalArgumentException When an index is invalid, i.e. {@code currentIndex} < 0, + * {@code currentIndex} >= {@link #getSize()}, {@code newIndex} < 0 + */ + public final Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) { + return moveMediaSourceRange(currentIndex, currentIndex + 1, newIndex, shuffleOrder); + } + + /** + * Moves a range of media sources within the playlist. + * + *

Note: when specified range is empty or the from index equals the new from index, no actual + * media source is moved and no exception is thrown. + * + * @param fromIndex The initial range index, pointing to the first media source of the range that + * will be moved. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be larger or equals than {@code fromIndex}. + * @param newFromIndex The target index of the first media source of the range that will be moved. + * @param shuffleOrder The new shuffle order. + * @return The new {@link Timeline}. + * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} < {@code fromIndex}, {@code fromIndex} > {@code toIndex}, {@code + * newFromIndex} < 0 + */ + public Timeline moveMediaSourceRange( + int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) { + Assertions.checkArgument( + fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize() && newFromIndex >= 0); + this.shuffleOrder = shuffleOrder; + if (fromIndex == toIndex || fromIndex == newFromIndex) { + return createTimeline(); + } + int startIndex = Math.min(fromIndex, newFromIndex); + int newEndIndex = newFromIndex + (toIndex - fromIndex) - 1; + int endIndex = Math.max(newEndIndex, toIndex - 1); + int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild; + moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); + for (int i = startIndex; i <= endIndex; i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + holder.firstWindowIndexInChild = windowOffset; + windowOffset += holder.mediaSource.getTimeline().getWindowCount(); + } + return createTimeline(); + } + + /** Clears the playlist. */ + public final Timeline clear(@Nullable ShuffleOrder shuffleOrder) { + this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear(); + removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ getSize()); + return createTimeline(); + } + + /** Whether the playlist is prepared. */ + public final boolean isPrepared() { + return isPrepared; + } + + /** Returns the number of media sources in the playlist. */ + public final int getSize() { + return mediaSourceHolders.size(); + } + + /** + * Sets the {@link AnalyticsCollector}. + * + * @param handler The handler on which to call the collector. + * @param analyticsCollector The analytics collector. + */ + public final void setAnalyticsCollector(Handler handler, AnalyticsCollector analyticsCollector) { + eventDispatcher.addEventListener(handler, analyticsCollector); + } + + /** + * Sets a new shuffle order to use when shuffling the child media sources. + * + * @param shuffleOrder A {@link ShuffleOrder}. + */ + public final Timeline setShuffleOrder(ShuffleOrder shuffleOrder) { + int size = getSize(); + if (shuffleOrder.getLength() != size) { + shuffleOrder = + shuffleOrder + .cloneAndClear() + .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size); + } + this.shuffleOrder = shuffleOrder; + return createTimeline(); + } + + /** Prepares the playlist. */ + public final void prepare(@Nullable TransferListener mediaTransferListener) { + Assertions.checkState(!isPrepared); + this.mediaTransferListener = mediaTransferListener; + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i); + prepareChildSource(mediaSourceHolder); + } + isPrepared = true; + } + + /** + * Returns a new {@link MediaPeriod} identified by {@code periodId}. + * + * @param id The identifier of the period. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. + * @return A new {@link MediaPeriod}. + */ + public MediaPeriod createPeriod( + MediaSource.MediaPeriodId id, Allocator allocator, long startPositionUs) { + Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid); + MediaSource.MediaPeriodId childMediaPeriodId = + id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)); + MediaSourceHolder holder = Assertions.checkNotNull(mediaSourceByUid.get(mediaSourceHolderUid)); + enableMediaSource(holder); + holder.activeMediaPeriodIds.add(childMediaPeriodId); + MediaPeriod mediaPeriod = + holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaSourceByMediaPeriod.put(mediaPeriod, holder); + disableUnusedMediaSources(); + return mediaPeriod; + } + + /** + * Releases the period. + * + * @param mediaPeriod The period to release. + */ + public final void releasePeriod(MediaPeriod mediaPeriod) { + MediaSourceHolder holder = + Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + holder.mediaSource.releasePeriod(mediaPeriod); + holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id); + if (!mediaSourceByMediaPeriod.isEmpty()) { + disableUnusedMediaSources(); + } + maybeReleaseChildSource(holder); + } + + /** Releases the playlist. */ + public final void release() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.releaseSource(childSource.caller); + childSource.mediaSource.removeEventListener(childSource.eventListener); + } + childSources.clear(); + enabledMediaSourceHolders.clear(); + isPrepared = false; + } + + /** Throws any pending error encountered while loading or refreshing. */ + public final void maybeThrowSourceInfoRefreshError() throws IOException { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + /** Creates a timeline reflecting the current state of the playlist. */ + public final Timeline createTimeline() { + if (mediaSourceHolders.isEmpty()) { + return Timeline.EMPTY; + } + int windowOffset = 0; + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i); + mediaSourceHolder.firstWindowIndexInChild = windowOffset; + windowOffset += mediaSourceHolder.mediaSource.getTimeline().getWindowCount(); + } + return new PlaylistTimeline(mediaSourceHolders, shuffleOrder); + } + + // Internal methods. + + private void enableMediaSource(MediaSourceHolder mediaSourceHolder) { + enabledMediaSourceHolders.add(mediaSourceHolder); + @Nullable MediaSourceAndListener enabledChild = childSources.get(mediaSourceHolder); + if (enabledChild != null) { + enabledChild.mediaSource.enable(enabledChild.caller); + } + } + + private void disableUnusedMediaSources() { + Iterator iterator = enabledMediaSourceHolders.iterator(); + while (iterator.hasNext()) { + MediaSourceHolder holder = iterator.next(); + if (holder.activeMediaPeriodIds.isEmpty()) { + @Nullable MediaSourceAndListener disabledChild = childSources.get(holder); + if (disabledChild != null) { + disabledChild.mediaSource.disable(disabledChild.caller); + } + iterator.remove(); + } + } + } + + private void removeMediaSourcesInternal(int fromIndex, int toIndex) { + for (int index = toIndex - 1; index >= fromIndex; index--) { + MediaSourceHolder holder = mediaSourceHolders.remove(index); + mediaSourceByUid.remove(holder.uid); + Timeline oldTimeline = holder.mediaSource.getTimeline(); + correctOffsets( + /* startIndex= */ index, /* windowOffsetUpdate= */ -oldTimeline.getWindowCount()); + holder.isRemoved = true; + if (isPrepared) { + maybeReleaseChildSource(holder); + } + } + } + + private void correctOffsets(int startIndex, int windowOffsetUpdate) { + for (int i = startIndex; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i); + mediaSourceHolder.firstWindowIndexInChild += windowOffsetUpdate; + } + } + + // Internal methods to manage child sources. + + @Nullable + private static MediaSource.MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + MediaSourceHolder mediaSourceHolder, MediaSource.MediaPeriodId mediaPeriodId) { + for (int i = 0; i < mediaSourceHolder.activeMediaPeriodIds.size(); i++) { + // Ensure the reported media period id has the same window sequence number as the one created + // by this media source. Otherwise it does not belong to this child source. + if (mediaSourceHolder.activeMediaPeriodIds.get(i).windowSequenceNumber + == mediaPeriodId.windowSequenceNumber) { + Object periodUid = getPeriodUid(mediaSourceHolder, mediaPeriodId.periodUid); + return mediaPeriodId.copyWithPeriodUid(periodUid); + } + } + return null; + } + + private static int getWindowIndexForChildWindowIndex( + MediaSourceHolder mediaSourceHolder, int windowIndex) { + return windowIndex + mediaSourceHolder.firstWindowIndexInChild; + } + + private void prepareChildSource(MediaSourceHolder holder) { + MediaSource mediaSource = holder.mediaSource; + MediaSource.MediaSourceCaller caller = + (source, timeline) -> playlistInfoListener.onPlaylistUpdateRequested(); + MediaSourceEventListener eventListener = new ForwardingEventListener(holder); + childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener)); + mediaSource.addEventListener(new Handler(), eventListener); + mediaSource.prepareSource(caller, mediaTransferListener); + } + + private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) { + // Release if the source has been removed from the playlist and no periods are still active. + if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) { + MediaSourceAndListener removedChild = + Assertions.checkNotNull(childSources.remove(mediaSourceHolder)); + removedChild.mediaSource.releaseSource(removedChild.caller); + removedChild.mediaSource.removeEventListener(removedChild.eventListener); + enabledMediaSourceHolders.remove(mediaSourceHolder); + } + } + + /** Return uid of media source holder from period uid of concatenated source. */ + private static Object getMediaSourceHolderUid(Object periodUid) { + return PlaylistTimeline.getChildTimelineUidFromConcatenatedUid(periodUid); + } + + /** Return uid of child period from period uid of concatenated source. */ + private static Object getChildPeriodUid(Object periodUid) { + return PlaylistTimeline.getChildPeriodUidFromConcatenatedUid(periodUid); + } + + private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodUid) { + return PlaylistTimeline.getConcatenatedUid(holder.uid, childPeriodUid); + } + + /* package */ static void moveMediaSourceHolders( + List mediaSourceHolders, int fromIndex, int toIndex, int newFromIndex) { + MediaSourceHolder[] removedItems = new MediaSourceHolder[toIndex - fromIndex]; + for (int i = removedItems.length - 1; i >= 0; i--) { + removedItems[i] = mediaSourceHolders.remove(fromIndex + i); + } + mediaSourceHolders.addAll( + Math.min(newFromIndex, mediaSourceHolders.size()), Arrays.asList(removedItems)); + } + + /** Data class to hold playlist media sources together with meta data needed to process them. */ + /* package */ static final class MediaSourceHolder { + + public final MaskingMediaSource mediaSource; + public final Object uid; + public final List activeMediaPeriodIds; + + public int firstWindowIndexInChild; + public boolean isRemoved; + + public MediaSourceHolder(MediaSource mediaSource, boolean useLazyPreparation) { + this.mediaSource = new MaskingMediaSource(mediaSource, useLazyPreparation); + this.activeMediaPeriodIds = new ArrayList<>(); + this.uid = new Object(); + } + + public void reset(int firstWindowIndexInChild) { + this.firstWindowIndexInChild = firstWindowIndexInChild; + this.isRemoved = false; + this.activeMediaPeriodIds.clear(); + } + } + + /** Timeline exposing concatenated timelines of playlist media sources. */ + /* package */ static final class PlaylistTimeline extends AbstractConcatenatedTimeline { + + private final int windowCount; + private final int periodCount; + private final int[] firstPeriodInChildIndices; + private final int[] firstWindowInChildIndices; + private final Timeline[] timelines; + private final Object[] uids; + private final HashMap childIndexByUid; + + public PlaylistTimeline( + Collection mediaSourceHolders, ShuffleOrder shuffleOrder) { + super(/* isAtomic= */ false, shuffleOrder); + int childCount = mediaSourceHolders.size(); + firstPeriodInChildIndices = new int[childCount]; + firstWindowInChildIndices = new int[childCount]; + timelines = new Timeline[childCount]; + uids = new Object[childCount]; + childIndexByUid = new HashMap<>(); + int index = 0; + int windowCount = 0; + int periodCount = 0; + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + timelines[index] = mediaSourceHolder.mediaSource.getTimeline(); + firstWindowInChildIndices[index] = windowCount; + firstPeriodInChildIndices[index] = periodCount; + windowCount += timelines[index].getWindowCount(); + periodCount += timelines[index].getPeriodCount(); + uids[index] = mediaSourceHolder.uid; + childIndexByUid.put(uids[index], index++); + } + this.windowCount = windowCount; + this.periodCount = periodCount; + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false); + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false); + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + Integer index = childIndexByUid.get(childUid); + return index == null ? C.INDEX_UNSET : index; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return timelines[childIndex]; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return firstPeriodInChildIndices[childIndex]; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return firstWindowInChildIndices[childIndex]; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return uids[childIndex]; + } + + @Override + public int getWindowCount() { + return windowCount; + } + + @Override + public int getPeriodCount() { + return periodCount; + } + } + + private static final class MediaSourceAndListener { + + public final MediaSource mediaSource; + public final MediaSource.MediaSourceCaller caller; + public final MediaSourceEventListener eventListener; + + public MediaSourceAndListener( + MediaSource mediaSource, + MediaSource.MediaSourceCaller caller, + MediaSourceEventListener eventListener) { + this.mediaSource = mediaSource; + this.caller = caller; + this.eventListener = eventListener; + } + } + + private final class ForwardingEventListener implements MediaSourceEventListener { + + private final Playlist.MediaSourceHolder id; + private EventDispatcher eventDispatcher; + + public ForwardingEventListener(Playlist.MediaSourceHolder id) { + eventDispatcher = Playlist.this.eventDispatcher; + this.id = id; + } + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.mediaPeriodCreated(); + } + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.mediaPeriodReleased(); + } + } + + @Override + public void onLoadStarted( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadStarted(loadEventData, mediaLoadData); + } + } + + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCompleted(loadEventData, mediaLoadData); + } + } + + @Override + public void onLoadCanceled( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCanceled(loadEventData, mediaLoadData); + } + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadError(loadEventData, mediaLoadData, error, wasCanceled); + } + } + + @Override + public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.readingStarted(); + } + } + + @Override + public void onUpstreamDiscarded( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.upstreamDiscarded(mediaLoadData); + } + } + + @Override + public void onDownstreamFormatChanged( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.downstreamFormatChanged(mediaLoadData); + } + } + + /** Updates the event dispatcher and returns whether the event should be dispatched. */ + private boolean maybeUpdateEventDispatcher( + int childWindowIndex, @Nullable MediaSource.MediaPeriodId childMediaPeriodId) { + @Nullable MediaSource.MediaPeriodId mediaPeriodId = null; + if (childMediaPeriodId != null) { + mediaPeriodId = getMediaPeriodIdForChildMediaPeriodId(id, childMediaPeriodId); + if (mediaPeriodId == null) { + // Media period not found. Ignore event. + return false; + } + } + int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); + if (eventDispatcher.windowIndex != windowIndex + || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) { + eventDispatcher = + Playlist.this.eventDispatcher.withParameters( + windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0L); + } + return true; + } + } +} + 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 43a5ebab99..de9802357c 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 @@ -43,6 +43,7 @@ import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; 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 class SimpleExoPlayer extends BasePlayer * @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 class SimpleExoPlayer extends BasePlayer loadControl, bandwidthMeter, analyticsCollector, + useLazyPreparation, clock, looper); } @@ -339,7 +343,6 @@ public class SimpleExoPlayer extends BasePlayer private int audioSessionId; private AudioAttributes audioAttributes; private float audioVolume; - @Nullable private MediaSource mediaSource; private List currentCues; @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; @Nullable private CameraMotionListener cameraMotionListener; @@ -355,6 +358,9 @@ public class SimpleExoPlayer extends BasePlayer * @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 @@ -368,6 +374,7 @@ public class SimpleExoPlayer extends BasePlayer LoadControl loadControl, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this( @@ -378,26 +385,14 @@ public class SimpleExoPlayer extends BasePlayer 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 @@ -409,6 +404,7 @@ public class SimpleExoPlayer extends BasePlayer @Nullable DrmSessionManager drmSessionManager, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this.bandwidthMeter = bandwidthMeter; @@ -439,7 +435,15 @@ public class SimpleExoPlayer extends BasePlayer // 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); @@ -1099,32 +1103,133 @@ public class SimpleExoPlayer extends BasePlayer } @Override + @Deprecated 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(); + } + + @Override + @Deprecated + @SuppressWarnings("deprecation") public void prepare(MediaSource mediaSource) { prepare(mediaSource, /* resetPosition= */ true, /* resetState= */ true); } @Override + @Deprecated public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { verifyApplicationThread(); - if (this.mediaSource != null) { - this.mediaSource.removeEventListener(analyticsCollector); - analyticsCollector.resetForNewMediaSource(); - } - this.mediaSource = mediaSource; - mediaSource.addEventListener(eventHandler, analyticsCollector); - @AudioFocusManager.PlayerCommand - int playerCommand = audioFocusManager.handlePrepare(getPlayWhenReady()); - updatePlayWhenReady(getPlayWhenReady(), playerCommand); - player.prepare(mediaSource, resetPosition, resetState); + setMediaItems( + Collections.singletonList(mediaSource), + /* startWindowIndex= */ resetPosition ? 0 : C.INDEX_UNSET, + /* startPositionMs= */ C.TIME_UNSET); + prepare(); + } + + @Override + public void setMediaItems(List mediaItems) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaItems(mediaItems); + } + + @Override + public void setMediaItems(List mediaItems, boolean resetPosition) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaItems(mediaItems, resetPosition); + } + + @Override + public void setMediaItems( + List mediaItems, int startWindowIndex, long startPositionMs) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaItems(mediaItems, startWindowIndex, startPositionMs); + } + + @Override + public void setMediaItem(MediaSource mediaItem) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaItem(mediaItem); + } + + @Override + public void setMediaItem(MediaSource mediaItem, long startPositionMs) { + verifyApplicationThread(); + analyticsCollector.resetForNewPlaylist(); + player.setMediaItem(mediaItem, startPositionMs); + } + + @Override + public void addMediaItem(MediaSource mediaSource) { + verifyApplicationThread(); + player.addMediaItem(mediaSource); + } + + @Override + public void addMediaItem(int index, MediaSource mediaSource) { + verifyApplicationThread(); + player.addMediaItem(index, mediaSource); + } + + @Override + public void addMediaItems(List mediaSources) { + verifyApplicationThread(); + player.addMediaItems(mediaSources); + } + + @Override + public void addMediaItems(int index, List mediaSources) { + verifyApplicationThread(); + player.addMediaItems(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 @@ -1204,6 +1309,7 @@ public class SimpleExoPlayer extends BasePlayer @Override public void setForegroundMode(boolean foregroundMode) { + verifyApplicationThread(); player.setForegroundMode(foregroundMode); } @@ -1211,13 +1317,6 @@ public class SimpleExoPlayer extends BasePlayer 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(); } @@ -1234,10 +1333,6 @@ public class SimpleExoPlayer extends BasePlayer } surface = null; } - if (mediaSource != null) { - mediaSource.removeEventListener(analyticsCollector); - mediaSource = null; - } if (isPriorityTaskManagerRegistered) { Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); isPriorityTaskManagerRegistered = false; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java index c496052f94..458532c86d 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/Timeline.java @@ -19,6 +19,7 @@ import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.util.Assertions; +import com.google.android.exoplayer2.util.Util; /** * A flexible representation of the structure of media. A timeline is able to represent the @@ -270,6 +271,46 @@ public abstract class Timeline { return positionInFirstPeriodUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Window that = (Window) obj; + return Util.areEqual(uid, that.uid) + && Util.areEqual(tag, that.tag) + && Util.areEqual(manifest, that.manifest) + && presentationStartTimeMs == that.presentationStartTimeMs + && windowStartTimeMs == that.windowStartTimeMs + && isSeekable == that.isSeekable + && isDynamic == that.isDynamic + && defaultPositionUs == that.defaultPositionUs + && durationUs == that.durationUs + && firstPeriodIndex == that.firstPeriodIndex + && lastPeriodIndex == that.lastPeriodIndex + && positionInFirstPeriodUs == that.positionInFirstPeriodUs; + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + uid.hashCode(); + result = 31 * result + (tag == null ? 0 : tag.hashCode()); + result = 31 * result + (manifest == null ? 0 : manifest.hashCode()); + result = 31 * result + (int) (presentationStartTimeMs ^ (presentationStartTimeMs >>> 32)); + result = 31 * result + (int) (windowStartTimeMs ^ (windowStartTimeMs >>> 32)); + result = 31 * result + (isSeekable ? 1 : 0); + result = 31 * result + (isDynamic ? 1 : 0); + result = 31 * result + (int) (defaultPositionUs ^ (defaultPositionUs >>> 32)); + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + firstPeriodIndex; + result = 31 * result + lastPeriodIndex; + result = 31 * result + (int) (positionInFirstPeriodUs ^ (positionInFirstPeriodUs >>> 32)); + return result; + } } /** @@ -526,6 +567,34 @@ public abstract class Timeline { return adPlaybackState.adResumePositionUs; } + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || !getClass().equals(obj.getClass())) { + return false; + } + Period that = (Period) obj; + return Util.areEqual(id, that.id) + && Util.areEqual(uid, that.uid) + && windowIndex == that.windowIndex + && durationUs == that.durationUs + && positionInWindowUs == that.positionInWindowUs + && Util.areEqual(adPlaybackState, that.adPlaybackState); + } + + @Override + public int hashCode() { + int result = 7; + result = 31 * result + (id == null ? 0 : id.hashCode()); + result = 31 * result + (uid == null ? 0 : uid.hashCode()); + result = 31 * result + windowIndex; + result = 31 * result + (int) (durationUs ^ (durationUs >>> 32)); + result = 31 * result + (int) (positionInWindowUs ^ (positionInWindowUs >>> 32)); + result = 31 * result + (adPlaybackState == null ? 0 : adPlaybackState.hashCode()); + return result; + } } /** An empty timeline. */ 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 43154a4b3f..2cb160d092 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 @@ -132,11 +132,8 @@ public class AnalyticsCollector } } - /** - * 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); @@ -786,9 +783,13 @@ public class AnalyticsCollector /** 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); @@ -804,7 +805,7 @@ public class AnalyticsCollector 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/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 8dfea1e511..cfd0ad9377 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -19,6 +19,7 @@ import android.os.Handler; import android.os.Message; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java index ac23e2a831..68bed250e8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -16,6 +16,7 @@ package com.google.android.exoplayer2.source; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.AbstractConcatenatedTimeline; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Player; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java index 8727fc5ed9..33bbf795be 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -17,6 +17,7 @@ package com.google.android.exoplayer2.source; import android.util.Pair; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline.Window; @@ -61,7 +62,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { } /** Returns the {@link Timeline}. */ - public Timeline getTimeline() { + public synchronized Timeline getTimeline() { return timeline; } @@ -129,7 +130,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { } @Override - protected void onChildSourceInfoRefreshed( + protected synchronized void onChildSourceInfoRefreshed( Void id, MediaSource mediaSource, Timeline newTimeline) { if (isPrepared) { timeline = timeline.cloneWithUpdatedTimeline(newTimeline); @@ -293,7 +294,8 @@ public final class MaskingMediaSource extends CompositeMediaSource { } /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ - private static final class DummyTimeline extends Timeline { + @VisibleForTesting + public static final class DummyTimeline extends Timeline { @Nullable private final Object tag; @@ -332,8 +334,8 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Override public Period getPeriod(int periodIndex, Period period, boolean setIds) { return period.set( - /* id= */ 0, - /* uid= */ MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID, + /* id= */ setIds ? 0 : null, + /* uid= */ setIds ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID : null, /* windowIndex= */ 0, /* durationUs = */ C.TIME_UNSET, /* positionInWindowUs= */ 0); 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 c37e98776e..66e78eb3a5 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 @@ -594,12 +594,10 @@ public class EventLogger implements AnalyticsListener { 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/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 5c50849010..e7a1f2e92e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.RendererCapabilities; import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioRendererEventListener; import com.google.android.exoplayer2.drm.DrmSessionManager; import com.google.android.exoplayer2.drm.FrameworkMediaCrypto; @@ -2010,6 +2011,42 @@ public final class Util { } } + /** + * Checks whether the timelines are the same. + * + * @param firstTimeline The first {@link Timeline}. + * @param secondTimeline The second {@link Timeline} to compare with. + * @return {@code true} if the both timelines are the same. + */ + public static boolean areTimelinesSame(Timeline firstTimeline, Timeline secondTimeline) { + if (firstTimeline == secondTimeline) { + return true; + } + if (secondTimeline.getWindowCount() != firstTimeline.getWindowCount() + || secondTimeline.getPeriodCount() != firstTimeline.getPeriodCount()) { + return false; + } + Timeline.Window firstWindow = new Timeline.Window(); + Timeline.Period firstPeriod = new Timeline.Period(); + Timeline.Window secondWindow = new Timeline.Window(); + Timeline.Period secondPeriod = new Timeline.Period(); + for (int i = 0; i < firstTimeline.getWindowCount(); i++) { + if (!firstTimeline + .getWindow(i, firstWindow) + .equals(secondTimeline.getWindow(i, secondWindow))) { + return false; + } + } + for (int i = 0; i < firstTimeline.getPeriodCount(); i++) { + if (!firstTimeline + .getPeriod(i, firstPeriod, /* setIds= */ true) + .equals(secondTimeline.getPeriod(i, secondPeriod, /* setIds= */ true))) { + return false; + } + } + return true; + } + private static HashMap createIso3ToIso2Map() { String[] iso2Languages = Locale.getISOLanguages(); HashMap iso3ToIso2 = 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 013acc5ee2..8fc5f90844 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 @@ -31,6 +31,7 @@ import com.google.android.exoplayer2.Timeline.Window; import com.google.android.exoplayer2.analytics.AnalyticsListener; import com.google.android.exoplayer2.source.ClippingMediaSource; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.MaskingMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; @@ -84,10 +85,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); } /** @@ -97,6 +100,7 @@ public final class ExoPlayerTest { @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() @@ -106,7 +110,10 @@ public final class ExoPlayerTest { .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(); @@ -127,8 +134,10 @@ public final class ExoPlayerTest { .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); @@ -150,8 +159,10 @@ public final class ExoPlayerTest { testRunner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - 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(3); assertThat(renderer.sampleBufferReadCount).isEqualTo(3); assertThat(renderer.isEnded).isTrue(); @@ -174,8 +185,10 @@ public final class ExoPlayerTest { 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(); @@ -247,14 +260,17 @@ public final class ExoPlayerTest { testRunner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); + testRunner.assertTimelinesSame(dummyTimeline, 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 testResettingMediaItemsGivesFreshSourceInfo() throws Exception { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); Object firstSourceManifest = new Object(); Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1, firstSourceManifest); @@ -270,8 +286,8 @@ public final class ExoPlayerTest { @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 setMediaItems() has returned. queuedSourceInfoCountDownLatch.countDown(); try { completePreparationCountDownLatch.await(); @@ -286,12 +302,13 @@ public final class ExoPlayerTest { // 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 setMediaItems() has returned. ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparation") - .waitForTimelineChanged(firstTimeline) - .prepareSource(secondSource) + new ActionSchedule.Builder("testResettingMediaItemsGivesFreshSourceInfo") + .waitForTimelineChanged( + firstTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .setMediaItems(secondSource) .executeRunnable( () -> { try { @@ -300,26 +317,32 @@ public final class ExoPlayerTest { // Ignore. } }) - .prepareSource(thirdSource) + .setMediaItems(thirdSource) .executeRunnable(completePreparationCountDownLatch::countDown) + .waitForPlaybackState(Player.STATE_READY) .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(); } @@ -331,7 +354,8 @@ public final class ExoPlayerTest { 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) @@ -366,8 +390,10 @@ public final class ExoPlayerTest { 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(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.isEnded).isTrue(); } @@ -396,7 +422,7 @@ public final class ExoPlayerTest { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(renderer) .setActionSchedule(actionSchedule) .build(context) @@ -442,12 +468,13 @@ public final class ExoPlayerTest { .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() @@ -542,26 +569,31 @@ public final class ExoPlayerTest { } @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 @@ -605,7 +637,7 @@ public final class ExoPlayerTest { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -633,7 +665,7 @@ public final class ExoPlayerTest { }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -659,7 +691,7 @@ public final class ExoPlayerTest { }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -677,7 +709,7 @@ public final class ExoPlayerTest { FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -706,7 +738,7 @@ public final class ExoPlayerTest { FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -743,7 +775,7 @@ public final class ExoPlayerTest { .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -782,7 +814,7 @@ public final class ExoPlayerTest { .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -806,31 +838,35 @@ public final class ExoPlayerTest { @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 testResetMediaItemsWithPositionResetAndShufflingUsesFirstPeriod() throws Exception { Timeline fakeTimeline = new FakeTimeline( new TimelineWindowDefinition( @@ -853,17 +889,19 @@ public final class ExoPlayerTest { .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) + .setMediaItems(/* 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); } @@ -908,7 +946,7 @@ public final class ExoPlayerTest { .executeRunnable(() -> fakeMediaPeriodHolder[0].setPreparationComplete()) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -941,8 +979,10 @@ public final class ExoPlayerTest { .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); } @@ -973,8 +1013,10 @@ public final class ExoPlayerTest { .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); } @@ -1005,9 +1047,11 @@ public final class ExoPlayerTest { .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); } @@ -1053,45 +1097,29 @@ public final class ExoPlayerTest { } @Test - public void testRepreparationDoesNotResetAfterStopWithReset() throws Exception { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaSource secondSource = new FakeMediaSource(timeline, Builder.VIDEO_FORMAT); - ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparationAfterStop") - .waitForPlaybackState(Player.STATE_READY) - .stop(/* reset= */ true) - .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(secondSource) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder() - .setTimeline(timeline) - .setActionSchedule(actionSchedule) - .setExpectedPlayerEndedCount(2) - .build(context) - .start() - .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, timeline); - testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertNoPositionDiscontinuities(); - } - - @Test - public void testSeekBeforeRepreparationPossibleAfterStopWithReset() throws Exception { + public void testSettingNewStartPositionPossibleAfterStopWithReset() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); 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("testSeekAfterStopWithReset") + new ActionSchedule.Builder("testSettingNewStartPositionPossibleAfterStopWithReset") .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) + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) + .setMediaItems(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() @@ -1101,32 +1129,43 @@ public final class ExoPlayerTest { .build(context) .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + testRunner.assertTimelinesSame( + dummyTimeline, timeline, Timeline.EMPTY, dummyTimeline, secondTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); + 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 testReprepareAndKeepPositionWithNewMediaSource() throws Exception { + public void testResetPlaylistWithPreviousPosition() throws Exception { + Object firstWindowId = new Object(); Timeline timeline = new FakeTimeline( - new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ new Object())); + 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= */ new Object())); + 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("testReprepareAndKeepPositionWithNewMediaSource") + new ActionSchedule.Builder("testResetPlaylistWithPreviousPosition") .pause() .waitForPlaybackState(Player.STATE_READY) .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) - .prepareSource(secondSource, /* resetPosition= */ false, /* resetState= */ true) - .waitForTimelineChanged(secondTimeline) + .setMediaItems(/* windowIndex= */ 0, /* positionMs= */ 2000, secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable( new PlayerRunnable() { @Override @@ -1145,10 +1184,68 @@ public final class ExoPlayerTest { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + testRunner.assertTimelinesSame( + firstExpectedMaskingTimeline, timeline, secondExpectedMaskingTimeline, 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); } + @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) + .setMediaItems(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 testStopDuringPreparationOverwritesPreparation() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); @@ -1167,8 +1264,10 @@ public final class ExoPlayerTest { .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); } @@ -1193,8 +1292,10 @@ public final class ExoPlayerTest { .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); } @@ -1206,8 +1307,7 @@ public final class ExoPlayerTest { .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ true, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_READY) .build(); ExoPlayerTestRunner testRunner = @@ -1221,9 +1321,10 @@ public final class ExoPlayerTest { } 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 @@ -1245,8 +1346,7 @@ public final class ExoPlayerTest { positionHolder[0] = player.getCurrentPosition(); } }) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ false, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @@ -1268,52 +1368,29 @@ public final class ExoPlayerTest { } 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() { @@ -1323,12 +1400,13 @@ public final class ExoPlayerTest { } }) .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); } @@ -1362,7 +1440,7 @@ public final class ExoPlayerTest { }) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1376,7 +1454,7 @@ public final class ExoPlayerTest { 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() @@ -1393,8 +1471,7 @@ public final class ExoPlayerTest { windowIndexHolder[0] = player.getCurrentWindowIndex(); } }) - .prepareSource(secondMediaSource, /* resetPosition= */ false, /* resetState= */ false) - .waitForPlaybackState(Player.STATE_BUFFERING) + .prepare() .executeRunnable( new PlayerRunnable() { @Override @@ -1402,7 +1479,6 @@ public final class ExoPlayerTest { // Position while repreparing. positionHolder[1] = player.getCurrentPosition(); windowIndexHolder[1] = player.getCurrentWindowIndex(); - secondMediaSource.setNewSourceInfo(timeline, /* newManifest= */ null); } }) .waitForPlaybackState(Player.STATE_READY) @@ -1419,7 +1495,7 @@ public final class ExoPlayerTest { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setTimeline(timeline) + .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build(context); try { @@ -1446,7 +1522,8 @@ public final class ExoPlayerTest { .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(); @@ -1463,7 +1540,7 @@ public final class ExoPlayerTest { }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .setAnalyticsListener(listener) .build(context); @@ -1479,14 +1556,15 @@ public final class ExoPlayerTest { @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) + .setMediaItems(/* resetPosition= */ false, mediaSource2) + .prepare() .waitForPlaybackState(Player.STATE_BUFFERING) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) @@ -1502,9 +1580,12 @@ public final class ExoPlayerTest { } 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 @@ -1534,7 +1615,8 @@ public final class ExoPlayerTest { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* positionMs= */ 50) .play() .build(); @@ -1628,17 +1710,12 @@ public final class ExoPlayerTest { new ActionSchedule.Builder("testSendMessages") .pause() .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .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) .build(); new Builder() @@ -1685,7 +1762,8 @@ public final class ExoPlayerTest { 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() @@ -1726,7 +1804,8 @@ public final class ExoPlayerTest { 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(); @@ -1805,14 +1884,16 @@ public final class ExoPlayerTest { 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() @@ -1828,7 +1909,7 @@ public final class ExoPlayerTest { 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(); @@ -1849,7 +1930,8 @@ public final class ExoPlayerTest { 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(); @@ -1878,15 +1960,17 @@ public final class ExoPlayerTest { 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() @@ -1920,7 +2004,7 @@ public final class ExoPlayerTest { .play() .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2058,16 +2142,21 @@ public final class ExoPlayerTest { /* 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); @@ -2109,7 +2198,7 @@ public final class ExoPlayerTest { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2227,6 +2316,56 @@ public final class ExoPlayerTest { 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().setMediaItem(secondMediaSource); + } + } + }; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testRecursiveTimelineChangeInStopAreReportedInCorrectOrder") + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + playerReference.set(player); + player.addListener(eventListener); + } + }) + .waitForTimelineChanged(firstTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + // Ensure there are no further pending callbacks. + .delay(1) + .stop(/* reset= */ true) + .prepare() + .waitForTimelineChanged(secondTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setActionSchedule(actionSchedule) + .setTimeline(firstTimeline) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, firstTimeline, Timeline.EMPTY, dummyTimeline, 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; @@ -2279,7 +2418,7 @@ public final class ExoPlayerTest { .build(); new ExoPlayerTestRunner.Builder() .setClock(clock) - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2313,7 +2452,7 @@ public final class ExoPlayerTest { List trackGroupsList = new ArrayList<>(); List trackSelectionsList = new ArrayList<>(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setSupportedFormats(Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT) .setActionSchedule(actionSchedule) .setEventListener( @@ -2361,7 +2500,7 @@ public final class ExoPlayerTest { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setRenderers(renderer) .build(context); try { @@ -2404,49 +2543,7 @@ public final class ExoPlayerTest { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(concatenatingMediaSource) - .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 failingDynamicUpdateOnlyThrowsWhenAvailablePeriodHasBeenFullyRead() throws Exception { - Timeline fakeTimeline = - 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(); - } - } - }; - ActionSchedule actionSchedule = - new ActionSchedule.Builder("testFailingDynamicMediaSourceInTimelineOnlyThrowsLater") - .pause() - .waitForPlaybackState(Player.STATE_READY) - .executeRunnable(() -> wasReadyOnce.set(true)) - .play() - .build(); - FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); - ExoPlayerTestRunner testRunner = - new Builder() - .setMediaSource(mediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .setRenderers(renderer) .build(context); @@ -2480,7 +2577,7 @@ public final class ExoPlayerTest { .executeRunnable(concatenatingMediaSource::clear) .build(); new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2504,7 +2601,7 @@ public final class ExoPlayerTest { .pause() .waitForPlaybackState(Player.STATE_BUFFERING) .seek(/* positionMs= */ 10) - .waitForTimelineChanged() + .waitForSeekProcessed() .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) .waitForTimelineChanged() .waitForPlaybackState(Player.STATE_READY) @@ -2518,7 +2615,7 @@ public final class ExoPlayerTest { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2549,7 +2646,7 @@ public final class ExoPlayerTest { .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) @@ -2564,7 +2661,7 @@ public final class ExoPlayerTest { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2665,10 +2762,10 @@ public final class ExoPlayerTest { player.addListener(eventListener); } }) - .seek(5_000) + .seek(/* positionMs= */ 5_000) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(fakeMediaSource) + .setMediaSources(fakeMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2766,6 +2863,264 @@ public final class ExoPlayerTest { .inOrder(); } + @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, + /* durationUs= */ C.TIME_UNSET), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET)); + 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); + + 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); + } + + @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); + + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET)); + 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); + } + + @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); + + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* durationUs= */ C.TIME_UNSET)); + 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); + } + + @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) + .clearMediaItems() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + 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 */); + } + + @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 */); + } + // Internal methods. private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { 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 afcce904e9..b137cd3cff 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 static org.mockito.Mockito.mock; 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; @@ -49,19 +51,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); @@ -69,7 +72,7 @@ public final class MediaPeriodQueueTest { @Test public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { - setupTimeline(/* initialPositionUs= */ 0); + setupTimeline(); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_UNSET, @@ -80,7 +83,7 @@ public final class MediaPeriodQueueTest { @Test public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos() { - setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ 0); + setupTimeline(/* adGroupTimesUs= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd(/* adGroupIndex= */ 0, /* contentPositionUs= */ 0); advance(); @@ -94,10 +97,7 @@ public final class MediaPeriodQueueTest { @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, @@ -132,10 +132,7 @@ public final class MediaPeriodQueueTest { @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, @@ -168,7 +165,7 @@ public final class MediaPeriodQueueTest { @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, @@ -188,10 +185,7 @@ public final class MediaPeriodQueueTest { @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. @@ -201,10 +195,8 @@ public final class MediaPeriodQueueTest { 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 = @@ -218,10 +210,7 @@ public final class MediaPeriodQueueTest { @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. @@ -232,10 +221,8 @@ public final class MediaPeriodQueueTest { 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,7 +237,6 @@ public final class MediaPeriodQueueTest { public void updateQueuedPeriods_withDurationChangeInReadingPeriodAfterReadingPosition_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -264,10 +250,8 @@ public final class MediaPeriodQueueTest { 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,7 +268,6 @@ public final class MediaPeriodQueueTest { public void updateQueuedPeriods_withDurationChangeInReadingPeriodBeforeReadingPosition_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -298,10 +281,8 @@ public final class MediaPeriodQueueTest { 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,7 +299,6 @@ public final class MediaPeriodQueueTest { public void updateQueuedPeriods_withDurationChangeInReadingPeriodReadToEnd_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -332,10 +312,8 @@ public final class MediaPeriodQueueTest { 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 = @@ -346,16 +324,25 @@ public final class MediaPeriodQueueTest { 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, @@ -369,6 +356,25 @@ public final class MediaPeriodQueueTest { /* 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()) { @@ -389,7 +395,7 @@ public final class MediaPeriodQueueTest { rendererCapabilities, trackSelector, allocator, - mediaSource, + playlist, getNextMediaPeriodInfo(), new TrackSelectorResult( new RendererConfiguration[0], new TrackSelection[0], /* info= */ null)); @@ -421,11 +427,6 @@ public final class MediaPeriodQueueTest { 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/PlaylistTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java new file mode 100644 index 0000000000..cc551db8ac --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java @@ -0,0 +1,510 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; +import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeShuffleOrder; +import com.google.android.exoplayer2.testutil.FakeTimeline; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link Playlist}. */ +@RunWith(AndroidJUnit4.class) +public class PlaylistTest { + + private static final int PLAYLIST_SIZE = 4; + + private Playlist playlist; + + @Before + public void setUp() { + playlist = new Playlist(mock(Playlist.PlaylistInfoRefreshListener.class)); + } + + @Test + public void testEmptyPlaylist_expectConstantTimelineInstanceEMPTY() { + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); + List fakeHolders = createFakeHolders(); + + Timeline timeline = playlist.setMediaSources(fakeHolders, shuffleOrder); + assertNotSame(timeline, Timeline.EMPTY); + + // Remove all media sources. + timeline = + playlist.removeMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ timeline.getWindowCount(), shuffleOrder); + assertSame(timeline, Timeline.EMPTY); + + timeline = playlist.setMediaSources(fakeHolders, shuffleOrder); + assertNotSame(timeline, Timeline.EMPTY); + // Clear. + timeline = playlist.clear(shuffleOrder); + assertSame(timeline, Timeline.EMPTY); + } + + @Test + public void testPrepareAndReprepareAfterRelease_expectSourcePreparationAfterPlaylistPrepare() { + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + playlist.setMediaSources( + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2)); + // Verify prepare is called once on prepare. + verify(mockMediaSource1, times(0)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(0)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + + playlist.prepare(/* mediaTransferListener= */ null); + assertThat(playlist.isPrepared()).isTrue(); + // Verify prepare is called once on prepare. + verify(mockMediaSource1, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + + playlist.release(); + playlist.prepare(/* mediaTransferListener= */ null); + // Verify prepare is called a second time on re-prepare. + verify(mockMediaSource1, times(2)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(2)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + } + + @Test + public void testSetMediaSources_playlistUnprepared_notUsingLazyPreparation() { + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + List mediaSources = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); + Timeline timeline = playlist.setMediaSources(mediaSources, shuffleOrder); + + assertThat(timeline.getWindowCount()).isEqualTo(2); + assertThat(playlist.getSize()).isEqualTo(2); + + // Assert holder offsets have been set properly + for (int i = 0; i < mediaSources.size(); i++) { + Playlist.MediaSourceHolder mediaSourceHolder = mediaSources.get(i); + assertThat(mediaSourceHolder.isRemoved).isFalse(); + assertThat(mediaSourceHolder.firstWindowIndexInChild).isEqualTo(i); + } + + // Set media items again. The second holder is re-used. + List moreMediaSources = + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + moreMediaSources.add(mediaSources.get(1)); + timeline = playlist.setMediaSources(moreMediaSources, shuffleOrder); + + assertThat(playlist.getSize()).isEqualTo(2); + assertThat(timeline.getWindowCount()).isEqualTo(2); + for (int i = 0; i < moreMediaSources.size(); i++) { + Playlist.MediaSourceHolder mediaSourceHolder = moreMediaSources.get(i); + assertThat(mediaSourceHolder.isRemoved).isFalse(); + assertThat(mediaSourceHolder.firstWindowIndexInChild).isEqualTo(i); + } + // Expect removed holders and sources to be removed without releasing. + verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSources.get(0).isRemoved).isTrue(); + // Expect re-used holder and source not to be removed. + verify(mockMediaSource2, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSources.get(1).isRemoved).isFalse(); + } + + @Test + public void testSetMediaSources_playlistPrepared_notUsingLazyPreparation() { + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2); + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + List mediaSources = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); + + playlist.prepare(/* mediaTransferListener= */ null); + playlist.setMediaSources(mediaSources, shuffleOrder); + + // Verify sources are prepared. + verify(mockMediaSource1, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + + // Set media items again. The second holder is re-used. + List moreMediaSources = + createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class)); + moreMediaSources.add(mediaSources.get(1)); + playlist.setMediaSources(moreMediaSources, shuffleOrder); + + // Expect removed holders and sources to be removed and released. + verify(mockMediaSource1, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSources.get(0).isRemoved).isTrue(); + // Expect re-used holder and source not to be removed but released. + verify(mockMediaSource2, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSources.get(1).isRemoved).isFalse(); + verify(mockMediaSource2, times(2)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + } + + @Test + public void testAddMediaSources_playlistUnprepared_notUsingLazyPreparation_expectUnprepared() { + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + List mediaSources = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); + playlist.addMediaSources(/* index= */ 0, mediaSources, new ShuffleOrder.DefaultShuffleOrder(2)); + + assertThat(playlist.getSize()).isEqualTo(2); + // Verify lazy initialization does not call prepare on sources. + verify(mockMediaSource1, times(0)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(0)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + + for (int i = 0; i < mediaSources.size(); i++) { + assertThat(mediaSources.get(i).firstWindowIndexInChild).isEqualTo(i); + assertThat(mediaSources.get(i).isRemoved).isFalse(); + } + + // Add for more sources in between. + List moreMediaSources = createFakeHolders(); + playlist.addMediaSources( + /* index= */ 1, moreMediaSources, new ShuffleOrder.DefaultShuffleOrder(/* length= */ 3)); + + assertThat(mediaSources.get(0).firstWindowIndexInChild).isEqualTo(0); + assertThat(moreMediaSources.get(0).firstWindowIndexInChild).isEqualTo(1); + assertThat(moreMediaSources.get(3).firstWindowIndexInChild).isEqualTo(4); + assertThat(mediaSources.get(1).firstWindowIndexInChild).isEqualTo(5); + } + + @Test + public void testAddMediaSources_playlistPrepared_notUsingLazyPreparation_expectPrepared() { + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + playlist.prepare(/* mediaTransferListener= */ null); + playlist.addMediaSources( + /* index= */ 0, + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2)); + + // Verify prepare is called on sources when added. + verify(mockMediaSource1, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + verify(mockMediaSource2, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + } + + @Test + public void testMoveMediaSources() { + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); + List holders = createFakeHolders(); + playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder); + + assertDefaultFirstWindowInChildIndexOrder(holders); + playlist.moveMediaSource(/* currentIndex= */ 0, /* newIndex= */ 3, shuffleOrder); + assertFirstWindowInChildIndices(holders, 3, 0, 1, 2); + playlist.moveMediaSource(/* currentIndex= */ 3, /* newIndex= */ 0, shuffleOrder); + assertDefaultFirstWindowInChildIndexOrder(holders); + + playlist.moveMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ 2, /* newFromIndex= */ 2, shuffleOrder); + assertFirstWindowInChildIndices(holders, 2, 3, 0, 1); + playlist.moveMediaSourceRange( + /* fromIndex= */ 2, /* toIndex= */ 4, /* newFromIndex= */ 0, shuffleOrder); + assertDefaultFirstWindowInChildIndexOrder(holders); + + playlist.moveMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ 2, /* newFromIndex= */ 2, shuffleOrder); + assertFirstWindowInChildIndices(holders, 2, 3, 0, 1); + playlist.moveMediaSourceRange( + /* fromIndex= */ 2, /* toIndex= */ 3, /* newFromIndex= */ 0, shuffleOrder); + assertFirstWindowInChildIndices(holders, 0, 3, 1, 2); + playlist.moveMediaSourceRange( + /* fromIndex= */ 3, /* toIndex= */ 4, /* newFromIndex= */ 1, shuffleOrder); + assertDefaultFirstWindowInChildIndexOrder(holders); + + // No-ops. + playlist.moveMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ 4, /* newFromIndex= */ 0, shuffleOrder); + assertDefaultFirstWindowInChildIndexOrder(holders); + playlist.moveMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ 0, /* newFromIndex= */ 3, shuffleOrder); + assertDefaultFirstWindowInChildIndexOrder(holders); + } + + @Test + public void testRemoveMediaSources_whenUnprepared_expectNoRelease() { + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + MediaSource mockMediaSource3 = mock(MediaSource.class); + MediaSource mockMediaSource4 = mock(MediaSource.class); + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); + + List holders = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, + mockMediaSource1, + mockMediaSource2, + mockMediaSource3, + mockMediaSource4); + playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder); + playlist.removeMediaSourceRange(/* fromIndex= */ 1, /* toIndex= */ 3, shuffleOrder); + + assertThat(playlist.getSize()).isEqualTo(2); + Playlist.MediaSourceHolder removedHolder1 = holders.remove(1); + Playlist.MediaSourceHolder removedHolder2 = holders.remove(1); + + assertDefaultFirstWindowInChildIndexOrder(holders); + assertThat(removedHolder1.isRemoved).isTrue(); + assertThat(removedHolder2.isRemoved).isTrue(); + verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource2, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource3, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource4, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + } + + @Test + public void testRemoveMediaSources_whenPrepared_expectRelease() { + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + MediaSource mockMediaSource3 = mock(MediaSource.class); + MediaSource mockMediaSource4 = mock(MediaSource.class); + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); + + List holders = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, + mockMediaSource1, + mockMediaSource2, + mockMediaSource3, + mockMediaSource4); + playlist.prepare(/* mediaTransferListener */ null); + playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder); + playlist.removeMediaSourceRange(/* fromIndex= */ 1, /* toIndex= */ 3, shuffleOrder); + + assertThat(playlist.getSize()).isEqualTo(2); + holders.remove(2); + holders.remove(1); + + assertDefaultFirstWindowInChildIndexOrder(holders); + verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource2, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource3, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + verify(mockMediaSource4, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + } + + @Test + public void testRelease_playlistUnprepared_expectSourcesNotReleased() { + MediaSource mockMediaSource = mock(MediaSource.class); + Playlist.MediaSourceHolder mediaSourceHolder = + new Playlist.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); + + playlist.setMediaSources( + Collections.singletonList(mediaSourceHolder), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + verify(mockMediaSource, times(0)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + playlist.release(); + verify(mockMediaSource, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSourceHolder.isRemoved).isFalse(); + } + + @Test + public void testRelease_playlistPrepared_expectSourcesReleasedNotRemoved() { + MediaSource mockMediaSource = mock(MediaSource.class); + Playlist.MediaSourceHolder mediaSourceHolder = + new Playlist.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false); + + playlist.prepare(/* mediaTransferListener= */ null); + playlist.setMediaSources( + Collections.singletonList(mediaSourceHolder), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + verify(mockMediaSource, times(1)) + .prepareSource( + any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull()); + playlist.release(); + verify(mockMediaSource, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class)); + assertThat(mediaSourceHolder.isRemoved).isFalse(); + } + + @Test + public void testClearPlaylist_expectSourcesReleasedAndRemoved() { + ShuffleOrder.DefaultShuffleOrder shuffleOrder = + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4); + MediaSource mockMediaSource1 = mock(MediaSource.class); + MediaSource mockMediaSource2 = mock(MediaSource.class); + List holders = + createFakeHoldersWithSources( + /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2); + playlist.setMediaSources(holders, shuffleOrder); + playlist.prepare(/* mediaTransferListener= */ null); + + Timeline timeline = playlist.clear(shuffleOrder); + assertThat(timeline.isEmpty()).isTrue(); + assertThat(holders.get(0).isRemoved).isTrue(); + assertThat(holders.get(1).isRemoved).isTrue(); + verify(mockMediaSource1, times(1)).releaseSource(any()); + verify(mockMediaSource2, times(1)).releaseSource(any()); + } + + @Test + public void testSetMediaSources_expectTimelineUsesCustomShuffleOrder() { + Timeline timeline = + playlist.setMediaSources(createFakeHolders(), new FakeShuffleOrder(/* length=*/ 4)); + assertTimelineUsesFakeShuffleOrder(timeline); + } + + @Test + public void testAddMediaSources_expectTimelineUsesCustomShuffleOrder() { + Timeline timeline = + playlist.addMediaSources( + /* index= */ 0, createFakeHolders(), new FakeShuffleOrder(PLAYLIST_SIZE)); + assertTimelineUsesFakeShuffleOrder(timeline); + } + + @Test + public void testMoveMediaSources_expectTimelineUsesCustomShuffleOrder() { + ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE); + playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder); + Timeline timeline = + playlist.moveMediaSource( + /* currentIndex= */ 0, /* newIndex= */ 1, new FakeShuffleOrder(PLAYLIST_SIZE)); + assertTimelineUsesFakeShuffleOrder(timeline); + } + + @Test + public void testMoveMediaSourceRange_expectTimelineUsesCustomShuffleOrder() { + ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE); + playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder); + Timeline timeline = + playlist.moveMediaSourceRange( + /* fromIndex= */ 0, + /* toIndex= */ 2, + /* newFromIndex= */ 2, + new FakeShuffleOrder(PLAYLIST_SIZE)); + assertTimelineUsesFakeShuffleOrder(timeline); + } + + @Test + public void testRemoveMediaSourceRange_expectTimelineUsesCustomShuffleOrder() { + ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE); + playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder); + Timeline timeline = + playlist.removeMediaSourceRange( + /* fromIndex= */ 0, /* toIndex= */ 2, new FakeShuffleOrder(/* length= */ 2)); + assertTimelineUsesFakeShuffleOrder(timeline); + } + + @Test + public void testSetShuffleOrder_expectTimelineUsesCustomShuffleOrder() { + playlist.setMediaSources( + createFakeHolders(), new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE)); + assertTimelineUsesFakeShuffleOrder( + playlist.setShuffleOrder(new FakeShuffleOrder(PLAYLIST_SIZE))); + } + + // Internal methods. + + private static void assertTimelineUsesFakeShuffleOrder(Timeline timeline) { + assertThat( + timeline.getNextWindowIndex( + /* windowIndex= */ 0, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true)) + .isEqualTo(-1); + assertThat( + timeline.getPreviousWindowIndex( + /* windowIndex= */ timeline.getWindowCount() - 1, + Player.REPEAT_MODE_OFF, + /* shuffleModeEnabled= */ true)) + .isEqualTo(-1); + } + + private static void assertDefaultFirstWindowInChildIndexOrder( + List holders) { + int[] indices = new int[holders.size()]; + for (int i = 0; i < indices.length; i++) { + indices[i] = i; + } + assertFirstWindowInChildIndices(holders, indices); + } + + private static void assertFirstWindowInChildIndices( + List holders, int... firstWindowInChildIndices) { + assertThat(holders).hasSize(firstWindowInChildIndices.length); + for (int i = 0; i < holders.size(); i++) { + assertThat(holders.get(i).firstWindowIndexInChild).isEqualTo(firstWindowInChildIndices[i]); + } + } + + private static List createFakeHolders() { + MediaSource fakeMediaSource = new FakeMediaSource(new FakeTimeline(1)); + List holders = new ArrayList<>(); + for (int i = 0; i < PLAYLIST_SIZE; i++) { + holders.add(new Playlist.MediaSourceHolder(fakeMediaSource, /* useLazyPreparation= */ true)); + } + return holders; + } + + private static List createFakeHoldersWithSources( + boolean useLazyPreparation, MediaSource... sources) { + List holders = new ArrayList<>(); + for (MediaSource mediaSource : sources) { + holders.add( + new Playlist.MediaSourceHolder( + mediaSource, /* useLazyPreparation= */ useLazyPreparation)); + } + return holders; + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java index d6e65cb34d..ba05af385a 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/TimelineTest.java @@ -15,6 +15,8 @@ */ package com.google.android.exoplayer2; +import static com.google.common.truth.Truth.assertThat; + import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline.TimelineWindowDefinition; @@ -58,4 +60,142 @@ public class TimelineTest { TimelineAsserts.assertNextWindowIndices(timeline, Player.REPEAT_MODE_ONE, false, 0); TimelineAsserts.assertNextWindowIndices(timeline, Player.REPEAT_MODE_ALL, false, 0); } + + @Test + public void testWindowEquals() { + Timeline.Window window = new Timeline.Window(); + assertThat(window).isEqualTo(new Timeline.Window()); + + Timeline.Window otherWindow = new Timeline.Window(); + otherWindow.tag = new Object(); + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.manifest = new Object(); + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.presentationStartTimeMs = C.TIME_UNSET; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.windowStartTimeMs = C.TIME_UNSET; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.isSeekable = true; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.isDynamic = true; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.defaultPositionUs = C.TIME_UNSET; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.durationUs = C.TIME_UNSET; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.firstPeriodIndex = 1; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.lastPeriodIndex = 1; + assertThat(window).isNotEqualTo(otherWindow); + + otherWindow = new Timeline.Window(); + otherWindow.positionInFirstPeriodUs = C.TIME_UNSET; + assertThat(window).isNotEqualTo(otherWindow); + + window.uid = new Object(); + window.tag = new Object(); + window.manifest = new Object(); + window.presentationStartTimeMs = C.TIME_UNSET; + window.windowStartTimeMs = C.TIME_UNSET; + window.isSeekable = true; + window.isDynamic = true; + window.defaultPositionUs = C.TIME_UNSET; + window.durationUs = C.TIME_UNSET; + window.firstPeriodIndex = 1; + window.lastPeriodIndex = 1; + window.positionInFirstPeriodUs = C.TIME_UNSET; + otherWindow = + otherWindow.set( + window.uid, + window.tag, + window.manifest, + window.presentationStartTimeMs, + window.windowStartTimeMs, + window.isSeekable, + window.isDynamic, + window.defaultPositionUs, + window.durationUs, + window.firstPeriodIndex, + window.lastPeriodIndex, + window.positionInFirstPeriodUs); + assertThat(window).isEqualTo(otherWindow); + } + + @Test + public void testWindowHashCode() { + Timeline.Window window = new Timeline.Window(); + Timeline.Window otherWindow = new Timeline.Window(); + assertThat(window.hashCode()).isEqualTo(otherWindow.hashCode()); + + window.tag = new Object(); + assertThat(window.hashCode()).isNotEqualTo(otherWindow.hashCode()); + otherWindow.tag = window.tag; + assertThat(window.hashCode()).isEqualTo(otherWindow.hashCode()); + } + + @Test + public void testPeriodEquals() { + Timeline.Period period = new Timeline.Period(); + assertThat(period).isEqualTo(new Timeline.Period()); + + Timeline.Period otherPeriod = new Timeline.Period(); + otherPeriod.id = new Object(); + assertThat(period).isNotEqualTo(otherPeriod); + + otherPeriod = new Timeline.Period(); + otherPeriod.uid = new Object(); + assertThat(period).isNotEqualTo(otherPeriod); + + otherPeriod = new Timeline.Period(); + otherPeriod.windowIndex = 12; + assertThat(period).isNotEqualTo(otherPeriod); + + otherPeriod = new Timeline.Period(); + otherPeriod.durationUs = 11L; + assertThat(period).isNotEqualTo(otherPeriod); + + otherPeriod = new Timeline.Period(); + period.id = new Object(); + period.uid = new Object(); + period.windowIndex = 1; + period.durationUs = 123L; + otherPeriod = + otherPeriod.set( + period.id, + period.uid, + period.windowIndex, + period.durationUs, + /* positionInWindowUs= */ 0); + assertThat(period).isEqualTo(otherPeriod); + } + + @Test + public void testPeriodHashCode() { + Timeline.Period period = new Timeline.Period(); + Timeline.Period otherPeriod = new Timeline.Period(); + assertThat(period.hashCode()).isEqualTo(otherPeriod.hashCode()); + + period.windowIndex = 12; + assertThat(period.hashCode()).isNotEqualTo(otherPeriod.hashCode()); + otherPeriod.windowIndex = period.windowIndex; + assertThat(period.hashCode()).isEqualTo(otherPeriod.hashCode()); + } } 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 ae87201c16..7117f426f3 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.source.TrackGroupArray; 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,7 +132,8 @@ public final class AnalyticsCollectorTest { 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(); } @@ -142,18 +142,19 @@ public final class AnalyticsCollectorTest { FakeMediaSource mediaSource = new FakeMediaSource( SINGLE_PERIOD_TIMELINE, - Builder.VIDEO_FORMAT, - Builder.AUDIO_FORMAT); + 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); @@ -184,12 +185,12 @@ public final class AnalyticsCollectorTest { new ConcatenatingMediaSource( new FakeMediaSource( SINGLE_PERIOD_TIMELINE, - Builder.VIDEO_FORMAT, - Builder.AUDIO_FORMAT), + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT), new FakeMediaSource( SINGLE_PERIOD_TIMELINE, - Builder.VIDEO_FORMAT, - Builder.AUDIO_FORMAT)); + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); populateEventIds(listener.lastReportedTimeline); @@ -199,7 +200,8 @@ public final class AnalyticsCollectorTest { 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); @@ -241,8 +243,8 @@ public final class AnalyticsCollectorTest { 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); @@ -254,7 +256,8 @@ public final class AnalyticsCollectorTest { 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); @@ -294,8 +297,8 @@ public final class AnalyticsCollectorTest { 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() @@ -316,7 +319,8 @@ public final class AnalyticsCollectorTest { 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); @@ -358,9 +362,11 @@ public final class AnalyticsCollectorTest { 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 = @@ -388,7 +394,8 @@ public final class AnalyticsCollectorTest { 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); @@ -436,18 +443,28 @@ public final class AnalyticsCollectorTest { @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) + .setMediaItems(/* 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 */, @@ -459,12 +476,16 @@ public final class AnalyticsCollectorTest { 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 /* setMediaItems */, period0Seq1 /* prepared */); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, @@ -498,19 +519,20 @@ public final class AnalyticsCollectorTest { @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 */, @@ -564,7 +586,7 @@ public final class AnalyticsCollectorTest { @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 = @@ -596,7 +618,11 @@ public final class AnalyticsCollectorTest { 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); @@ -650,7 +676,7 @@ public final class AnalyticsCollectorTest { .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); } @@ -717,7 +743,7 @@ public final class AnalyticsCollectorTest { TestAnalyticsListener listener = new TestAnalyticsListener(); try { new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderersFactory(renderersFactory) .setAnalyticsListener(listener) .setActionSchedule(actionSchedule) @@ -739,7 +765,7 @@ public final class AnalyticsCollectorTest { 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(); } @@ -797,7 +823,7 @@ public final class AnalyticsCollectorTest { 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(); } @@ -881,10 +907,12 @@ public final class AnalyticsCollectorTest { public Timeline lastReportedTimeline; + private final List reportedTimelines; private final ArrayList reportedEvents; public TestAnalyticsListener() { reportedEvents = new ArrayList<>(); + reportedTimelines = new ArrayList<>(); lastReportedTimeline = Timeline.EMPTY; } @@ -914,6 +942,7 @@ public final class AnalyticsCollectorTest { @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 c4116c3696..dada94df0a 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 androidx.annotation.Nullable; 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; @@ -28,6 +29,7 @@ import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.SimpleExoPlayer; 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.testutil.ActionSchedule.ActionNode; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; @@ -36,10 +38,10 @@ import com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Paramet import com.google.android.exoplayer2.util.ConditionVariable; import com.google.android.exoplayer2.util.HandlerWrapper; import com.google.android.exoplayer2.util.Log; +import java.util.Arrays; +import java.util.List; -/** - * Base class for actions to perform during playback tests. - */ +/** Base class for actions to perform during playback tests. */ public abstract class Action { private final String tag; @@ -109,13 +111,12 @@ public abstract class Action { protected abstract void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface); - /** - * Calls {@link Player#seekTo(long)} or {@link Player#seekTo(int, long)}. - */ + /** Calls {@link Player#seekTo(long)} or {@link Player#seekTo(int, long)}. */ 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)}. @@ -127,6 +128,7 @@ public abstract class Action { super(tag, "Seek:" + positionMs); this.windowIndex = null; this.positionMs = positionMs; + catchIllegalSeekException = false; } /** @@ -135,28 +137,171 @@ public abstract class Action { * @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 Player#stop()} or {@link Player#stop(boolean)}. - */ + /** Calls {@link SimpleExoPlayer#setMediaItems(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.setMediaItems(Arrays.asList(mediaSources), windowIndex, positionMs); + } + } + + /** Calls {@link SimpleExoPlayer#setMediaItems(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.setMediaItems(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 { private static final String STOP_ACTION_TAG = "Stop"; @@ -192,14 +337,10 @@ public abstract class Action { } else { player.stop(reset); } - } - } - /** - * Calls {@link Player#setPlayWhenReady(boolean)}. - */ + /** Calls {@link Player#setPlayWhenReady(boolean)}. */ public static final class SetPlayWhenReady extends Action { private final boolean playWhenReady; @@ -218,7 +359,6 @@ public abstract class Action { SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlayWhenReady(playWhenReady); } - } /** @@ -247,17 +387,12 @@ public abstract class Action { trackSelector.setParameters( trackSelector.buildUponParameters().setRendererDisabled(rendererIndex, disabled)); } - } - /** - * Calls {@link SimpleExoPlayer#clearVideoSurface()}. - */ + /** Calls {@link SimpleExoPlayer#clearVideoSurface()}. */ public static final class ClearVideoSurface extends Action { - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public ClearVideoSurface(String tag) { super(tag, "ClearVideoSurface"); } @@ -267,17 +402,12 @@ public abstract class Action { SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.clearVideoSurface(); } - } - /** - * Calls {@link SimpleExoPlayer#setVideoSurface(Surface)}. - */ + /** Calls {@link SimpleExoPlayer#setVideoSurface(Surface)}. */ public static final class SetVideoSurface extends Action { - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public SetVideoSurface(String tag) { super(tag, "SetVideoSurface"); } @@ -287,56 +417,30 @@ public abstract class Action { SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setVideoSurface(surface); } - } - /** - * 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. - */ - public PrepareSource(String tag, MediaSource mediaSource) { - this(tag, mediaSource, true, true); - } - - /** - * @param tag A tag to use for logging. - */ - 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)}. - */ + /** 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 tag A tag to use for logging. */ public SetRepeatMode(String tag, @Player.RepeatMode int repeatMode) { - super(tag, "SetRepeatMode:" + repeatMode); + super(tag, "SetRepeatMode: " + repeatMode); this.repeatMode = repeatMode; } @@ -345,21 +449,37 @@ public abstract class Action { SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setRepeatMode(repeatMode); } - } - /** - * Calls {@link Player#setShuffleModeEnabled(boolean)}. - */ + /** 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 { private final boolean shuffleModeEnabled; - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public SetShuffleModeEnabled(String tag, boolean shuffleModeEnabled) { - super(tag, "SetShuffleModeEnabled:" + shuffleModeEnabled); + super(tag, "SetShuffleModeEnabled: " + shuffleModeEnabled); this.shuffleModeEnabled = shuffleModeEnabled; } @@ -427,9 +547,7 @@ public abstract class Action { } } - /** - * Calls {@link Player#setPlaybackParameters(PlaybackParameters)}. - */ + /** Calls {@link Player#setPlaybackParameters(PlaybackParameters)}. */ public static final class SetPlaybackParameters extends Action { private final PlaybackParameters playbackParameters; @@ -448,7 +566,6 @@ public abstract class Action { SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlaybackParameters(playbackParameters); } - } /** Throws a playback exception on the playback thread. */ @@ -545,18 +662,35 @@ public abstract class Action { /** Waits for {@link Player.EventListener#onTimelineChanged(Timeline, int)}. */ public static final class WaitForTimelineChanged extends Action { - @Nullable private final Timeline expectedTimeline; + private final Timeline expectedTimeline; + private final boolean ignoreExpectedReason; + @Player.TimelineChangeReason private final int expectedReason; /** - * Creates action waiting for a timeline change. + * Creates action waiting for a timeline change for a given reason. * * @param tag A tag to use for logging. - * @param expectedTimeline The expected timeline to wait for. If null, wait for any timeline - * change. + * @param expectedTimeline The expected timeline or null if any timeline change is relevant. + * @param expectedReason The expected timeline change reason. */ - public WaitForTimelineChanged(String tag, @Nullable Timeline expectedTimeline) { + public WaitForTimelineChanged( + String tag, Timeline expectedTimeline, @Player.TimelineChangeReason int expectedReason) { super(tag, "WaitForTimelineChanged"); this.expectedTimeline = expectedTimeline; + this.ignoreExpectedReason = false; + this.expectedReason = expectedReason; + } + + /** + * Creates action waiting for any timeline change for any reason. + * + * @param tag A tag to use for logging. + */ + public WaitForTimelineChanged(String tag) { + super(tag, "WaitForTimelineChanged"); + this.expectedTimeline = null; + this.ignoreExpectedReason = true; + this.expectedReason = Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED; } @Override @@ -574,7 +708,9 @@ public abstract class Action { @Override public void onTimelineChanged( Timeline timeline, @Player.TimelineChangeReason int reason) { - if (expectedTimeline == null || timeline.equals(expectedTimeline)) { + if ((expectedTimeline == null + || TestUtil.areTimelinesSame(expectedTimeline, timeline)) + && (ignoreExpectedReason || expectedReason == reason)) { player.removeListener(this); nextAction.schedule(player, trackSelector, surface, handler); } @@ -594,14 +730,10 @@ public abstract class Action { } } - /** - * Waits for {@link Player.EventListener#onPositionDiscontinuity(int)}. - */ + /** Waits for {@link Player.EventListener#onPositionDiscontinuity(int)}. */ public static final class WaitForPositionDiscontinuity extends Action { - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public WaitForPositionDiscontinuity(String tag) { super(tag, "WaitForPositionDiscontinuity"); } @@ -634,16 +766,14 @@ public abstract class Action { } /** - * Waits for a specified playback state, returning either immediately or after a call to - * {@link Player.EventListener#onPlayerStateChanged(boolean, int)}. + * Waits for a specified playback state, returning either immediately or after a call to {@link + * Player.EventListener#onPlayerStateChanged(boolean, int)}. */ public static final class WaitForPlaybackState extends Action { private final int targetPlaybackState; - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public WaitForPlaybackState(String tag, int targetPlaybackState) { super(tag, "WaitForPlaybackState"); this.targetPlaybackState = targetPlaybackState; @@ -733,14 +863,10 @@ public abstract class Action { } } - /** - * Waits for {@link Player.EventListener#onSeekProcessed()}. - */ + /** Waits for {@link Player.EventListener#onSeekProcessed()}. */ public static final class WaitForSeekProcessed extends Action { - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public WaitForSeekProcessed(String tag) { super(tag, "WaitForSeekProcessed"); } @@ -772,16 +898,12 @@ public abstract class Action { } } - /** - * Calls {@link Runnable#run()}. - */ + /** Calls {@code Runnable.run()}. */ public static final class ExecuteRunnable extends Action { private final Runnable runnable; - /** - * @param tag A tag to use for logging. - */ + /** @param tag A tag to use for logging. */ public ExecuteRunnable(String tag, Runnable runnable) { super(tag, "ExecuteRunnable"); this.runnable = runnable; @@ -795,7 +917,5 @@ public abstract class Action { } runnable.run(); } - } - } 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 e8abfdb73f..3a1ca37c55 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 @@ -27,10 +27,10 @@ import com.google.android.exoplayer2.PlayerMessage.Target; import com.google.android.exoplayer2.SimpleExoPlayer; 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.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.SetPlayWhenReady; @@ -38,6 +38,7 @@ import com.google.android.exoplayer2.testutil.Action.SetPlaybackParameters; 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; @@ -169,7 +170,19 @@ public final class ActionSchedule { * @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)); } /** @@ -301,23 +314,90 @@ public final class ActionSchedule { } /** - * Schedules a new source preparation action to be executed. + * 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 prepareSource(MediaSource mediaSource) { - return apply(new PrepareSource(tag, mediaSource)); + public Builder setMediaItems(int windowIndex, long positionMs, MediaSource... sources) { + return apply(new Action.SetMediaItems(tag, windowIndex, positionMs, sources)); } /** - * Schedules a new source preparation action to be executed. + * 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 setMediaItems(boolean resetPosition, MediaSource... sources) { + return apply(new Action.SetMediaItemsResetPosition(tag, resetPosition, sources)); + } + + /** + * Schedules a set media items action to be executed. + * + * @return The builder, for convenience. + */ + public Builder setMediaItems(MediaSource... sources) { + return apply( + new Action.SetMediaItems( + tag, /* windowIndex */ C.INDEX_UNSET, /* positionUs */ C.TIME_UNSET, sources)); + } + + /** + * 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. * @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 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 removeMediaItems(int fromIndex, int toIndex) { + return apply(new Action.RemoveMediaItems(tag, fromIndex, toIndex)); + } + + /** + * Schedules a prepare action to be executed. + * + * @return The builder, for convenience. + */ + 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)); } /** @@ -329,6 +409,16 @@ public final class ActionSchedule { return apply(new SetRepeatMode(tag, repeatMode)); } + /** + * 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. * @@ -382,18 +472,19 @@ public final class ActionSchedule { * @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)); } /** diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java index 5f01d7724b..b00ad287bb 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java @@ -141,7 +141,8 @@ public abstract class ExoHostedTest implements AnalyticsListener, HostedTest { pendingSchedule = null; } DrmSessionManager drmSessionManager = buildDrmSessionManager(userAgent); - player.prepare(buildSource(host, Util.getUserAgent(host, userAgent), drmSessionManager)); + player.setMediaItem(buildSource(host, Util.getUserAgent(host, userAgent), drmSessionManager)); + player.prepare(); } @Override 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 7db1987d5b..7b53c8d964 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,6 +16,7 @@ package com.google.android.exoplayer2.testutil; import static com.google.common.truth.Truth.assertThat; +import static junit.framework.TestCase.assertTrue; import android.content.Context; import android.os.HandlerThread; @@ -44,6 +45,7 @@ import com.google.android.exoplayer2.util.HandlerWrapper; 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; @@ -72,8 +74,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private Clock clock; private Timeline timeline; + private List mediaSources; private Object manifest; - private MediaSource mediaSource; private DefaultTrackSelector trackSelector; private LoadControl loadControl; private BandwidthMeter bandwidthMeter; @@ -85,50 +87,53 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private AnalyticsListener analyticsListener; private Integer expectedPlayerEndedCount; + public Builder() { + mediaSources = new ArrayList<>(); + } + /** * 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)}. + * 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 #setMediaSources(MediaSource...)}. * * @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(); this.timeline = timeline; return this; } /** * 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)}. + * is null. Setting the manifest is not allowed after a call to {@link + * #setMediaSources(MediaSource...)}. * * @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(); this.manifest = manifest; return this; } /** - * Sets a {@link MediaSource} 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)}. + * 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 media sources is not allowed after calls to {@link + * #setTimeline(Timeline)} and/or {@link #setManifest(Object)}. * - * @param mediaSource A {@link MediaSource} to be used by the test runner. + * @param mediaSources The {@link MediaSource}s to be used by the test runner. * @return This builder. */ - public Builder setMediaSource(MediaSource mediaSource) { + public Builder setMediaSources(MediaSource... mediaSources) { assertThat(timeline).isNull(); assertThat(manifest).isNull(); - this.mediaSource = mediaSource; + this.mediaSources = Arrays.asList(mediaSources); return this; } @@ -170,10 +175,10 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc /** * 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 #setRenderers(Renderer...)} or {@link #setRenderersFactory(RenderersFactory)} are set. + * 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 #setMediaSources(MediaSource...)} and renderers with {@link + * #setRenderers(Renderer...)} or {@link #setRenderersFactory(RenderersFactory)} are set. * * @param supportedFormats A list of supported {@link Format}s. * @return This builder. @@ -226,7 +231,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc /** * 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. @@ -307,11 +312,11 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc if (clock == null) { clock = new AutoAdvancingFakeClock(); } - if (mediaSource == null) { + if (mediaSources.isEmpty()) { 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; @@ -319,7 +324,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc return new ExoPlayerTestRunner( context, clock, - mediaSource, + mediaSources, renderersFactory, trackSelector, loadControl, @@ -333,7 +338,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private final Context context; private final Clock clock; - private final MediaSource mediaSource; + private final List mediaSources; private final RenderersFactory renderersFactory; private final DefaultTrackSelector trackSelector; private final LoadControl loadControl; @@ -350,6 +355,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private final ArrayList timelineChangeReasons; private final ArrayList periodIndices; private final ArrayList discontinuityReasons; + private final ArrayList playbackStates; private SimpleExoPlayer player; private Exception exception; @@ -359,7 +365,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc private ExoPlayerTestRunner( Context context, Clock clock, - MediaSource mediaSource, + List mediaSources, RenderersFactory renderersFactory, DefaultTrackSelector trackSelector, LoadControl loadControl, @@ -370,7 +376,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc int expectedPlayerEndedCount) { this.context = context; this.clock = clock; - this.mediaSource = mediaSource; + this.mediaSources = mediaSources; this.renderersFactory = renderersFactory; this.trackSelector = trackSelector; this.loadControl = loadControl; @@ -382,6 +388,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc 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"); @@ -416,7 +423,8 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc if (actionSchedule != null) { actionSchedule.start(player, trackSelector, null, handler, ExoPlayerTestRunner.this); } - player.prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); + player.setMediaItems(mediaSources, /* resetPosition= */ false); + player.prepare(); } catch (Exception e) { handleException(e); } @@ -468,12 +476,16 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc /** * 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++) { + assertTrue(TestUtil.areTimelinesSame(timelines[i], this.timelines.get(i))); + } } /** @@ -486,9 +498,18 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc } /** - * Asserts that the last track group array reported by - * {@link Player.EventListener#onTracksChanged(TrackGroupArray, TrackSelectionArray)} is equal to - * the provided track group array. + * 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 + * provided track group array. * * @param trackGroupArray The expected {@link TrackGroupArray}. */ @@ -560,10 +581,12 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc @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); } } @@ -574,6 +597,7 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc @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)) { @@ -620,9 +644,9 @@ public final class ExoPlayerTestRunner implements Player.EventListener, ActionSc renderersFactory, trackSelector, loadControl, - /* drmSessionManager= */ null, bandwidthMeter, new AnalyticsCollector(clock), + /* useLazyPreparation= */ false, clock, Looper.myLooper()); } 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 18eaec2cd7..a826e73e16 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.PlayerMessage; 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} @@ -96,6 +98,11 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { throw new UnsupportedOperationException(); } + @Override + public void prepare() { + throw new UnsupportedOperationException(); + } + @Override public void prepare(MediaSource mediaSource) { throw new UnsupportedOperationException(); @@ -106,6 +113,77 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { throw new UnsupportedOperationException(); } + @Override + public void setMediaItems(List mediaItems, boolean resetPosition) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaItems(List mediaItems) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaItems( + List mediaItems, int startWindowIndex, long startPositionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaItem(MediaSource mediaItem, long startPositionMs) { + throw new UnsupportedOperationException(); + } + + @Override + public void setMediaItem(MediaSource mediaItem) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaItem(MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaItem(int index, MediaSource mediaSource) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaItems(List mediaSources) { + throw new UnsupportedOperationException(); + } + + @Override + public void addMediaItems(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(); @@ -126,6 +204,11 @@ public abstract class StubExoPlayer extends BasePlayer implements ExoPlayer { throw new UnsupportedOperationException(); } + @Override + public void setShuffleOrder(ShuffleOrder shuffleOrder) { + throw new UnsupportedOperationException(); + } + @Override public void setShuffleModeEnabled(boolean shuffleModeEnabled) { throw new UnsupportedOperationException(); diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java index facfa0d7e4..52c12f78b2 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/TestUtil.java @@ -26,6 +26,7 @@ import android.graphics.BitmapFactory; import android.graphics.Color; import android.net.Uri; import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.database.DatabaseProvider; import com.google.android.exoplayer2.database.DefaultDatabaseProvider; import com.google.android.exoplayer2.extractor.DefaultExtractorInput; @@ -398,4 +399,61 @@ public class TestUtil { } return new DefaultExtractorInput(dataSource, position, length); } + + /** + * Checks whether the timelines are the same (does not compare {@link Timeline.Window#uid} and + * {@link Timeline.Period#uid}). + * + * @param firstTimeline The first {@link Timeline}. + * @param secondTimeline The second {@link Timeline} to compare with. + * @return {@code true} if both timelines are the same. + */ + public static boolean areTimelinesSame(Timeline firstTimeline, Timeline secondTimeline) { + if (firstTimeline == secondTimeline) { + return true; + } + if (secondTimeline.getWindowCount() != firstTimeline.getWindowCount() + || secondTimeline.getPeriodCount() != firstTimeline.getPeriodCount()) { + return false; + } + Timeline.Window firstWindow = new Timeline.Window(); + Timeline.Period firstPeriod = new Timeline.Period(); + Timeline.Window secondWindow = new Timeline.Window(); + Timeline.Period secondPeriod = new Timeline.Period(); + for (int i = 0; i < firstTimeline.getWindowCount(); i++) { + if (!areWindowsSame( + firstTimeline.getWindow(i, firstWindow), secondTimeline.getWindow(i, secondWindow))) { + return false; + } + } + for (int i = 0; i < firstTimeline.getPeriodCount(); i++) { + if (!firstTimeline + .getPeriod(i, firstPeriod, /* setIds= */ false) + .equals(secondTimeline.getPeriod(i, secondPeriod, /* setIds= */ false))) { + return false; + } + } + return true; + } + + /** + * Checks whether the windows are the same. This comparison does not compare the uid. + * + * @param first The first {@link Timeline.Window}. + * @param second The second {@link Timeline.Window}. + * @return true if both windows are the same. + */ + private static boolean areWindowsSame(Timeline.Window first, Timeline.Window second) { + return Util.areEqual(first.tag, second.tag) + && Util.areEqual(first.manifest, second.manifest) + && first.presentationStartTimeMs == second.presentationStartTimeMs + && first.windowStartTimeMs == second.windowStartTimeMs + && first.isSeekable == second.isSeekable + && first.isDynamic == second.isDynamic + && first.defaultPositionUs == second.defaultPositionUs + && first.durationUs == second.durationUs + && first.firstPeriodIndex == second.firstPeriodIndex + && first.lastPeriodIndex == second.lastPeriodIndex + && first.positionInFirstPeriodUs == second.positionInFirstPeriodUs; + } }