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 eaf1829d6a5..95c1073f574 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 @@ -50,7 +50,6 @@ import com.google.android.exoplayer2.offline.DownloadHelper; import com.google.android.exoplayer2.offline.DownloadRequest; import com.google.android.exoplayer2.source.BehindLiveWindowException; -import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSourceFactory; import com.google.android.exoplayer2.source.ProgressiveMediaSource; @@ -79,6 +78,8 @@ import java.net.CookieHandler; import java.net.CookieManager; import java.net.CookiePolicy; +import java.util.ArrayList; +import java.util.List; /** An activity that plays media using {@link SimpleExoPlayer}. */ public class PlayerActivity extends AppCompatActivity @@ -140,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; @@ -342,12 +343,10 @@ public void onVisibilityChange(int visibility) { private void initializePlayer() { if (player == null) { Intent intent = getIntent(); - - mediaSource = createTopLevelMediaSource(intent); - if (mediaSource == null) { + mediaSources = createTopLevelMediaSources(intent); + if (mediaSources.isEmpty()) { return; } - TrackSelection.Factory trackSelectionFactory; String abrAlgorithm = intent.getStringExtra(ABR_ALGORITHM_EXTRA); if (abrAlgorithm == null || ABR_ALGORITHM_DEFAULT.equals(abrAlgorithm)) { @@ -388,12 +387,12 @@ private void initializePlayer() { 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 +420,30 @@ private MediaSource createTopLevelMediaSource(Intent intent) { } } - 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); + if (!adTagUri.equals(loadedAdTagUri)) { + releaseAdsLoader(); + loadedAdTagUri = adTagUri; + } + MediaSource adsMediaSource = createAdsMediaSource(mediaSources.get(0), adTagUri); + if (adsMediaSource != null) { + mediaSources.set(0, adsMediaSource); } else { - if (!adTagUri.equals(loadedAdTagUri)) { - releaseAdsLoader(); - loadedAdTagUri = adTagUri; - } - MediaSource adsMediaSource = createAdsMediaSource(mediaSource, adTagUri); - if (adsMediaSource != null) { - mediaSource = adsMediaSource; - } else { - showToast(R.string.ima_not_loaded); - } + showToast(R.string.ima_not_loaded); } + } else if (seenAdsTagUri && mediaSources.size() > 1) { + showToast(R.string.unsupported_ads_in_concatenation); + releaseAdsLoader(); } else { releaseAdsLoader(); } - return mediaSource; + return mediaSources; } private MediaSource createLeafMediaSource(UriSample parameters) { @@ -535,7 +530,7 @@ private void releasePlayer() { debugViewHelper = null; player.release(); player = null; - mediaSource = null; + mediaSources = null; trackSelector = null; } if (adsLoader != null) { diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 2fd5821928a..2180fe5ca74 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 @@ -112,7 +112,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. @@ -174,7 +173,6 @@ public PendingResult loadItems( MediaQueueItem[] items, int startIndex, long positionMs, @RepeatMode int repeatMode) { if (remoteMediaClient != null) { positionMs = positionMs != C.TIME_UNSET ? positionMs : 0; - waitingForInitialTimeline = true; return remoteMediaClient.queueLoad(items, startIndex, getCastRepeatMode(repeatMode), positionMs, null); } @@ -619,15 +617,13 @@ private void updateRepeatModeAndNotifyIfChanged() { private void updateTimelineAndNotifyIfChanged() { if (updateTimeline()) { - @Player.TimelineChangeReason - int reason = - waitingForInitialTimeline - ? Player.TIMELINE_CHANGE_REASON_PREPARED - : Player.TIMELINE_CHANGE_REASON_DYNAMIC; - waitingForInitialTimeline = false; + // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and + // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553]. notificationsBatch.add( new ListenerNotificationTask( - listener -> listener.onTimelineChanged(currentTimeline, reason))); + listener -> + listener.onTimelineChanged( + currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE))); } } diff --git a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/FakePlayer.java index a9572b7a8d9..a6a725ee9e9 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 @@ 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 @@ public FakePlayer() { 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 edaa4cde29b..2452da474d5 100644 --- a/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java +++ b/extensions/ima/src/test/java/com/google/android/exoplayer2/ext/ima/ImaAdsLoaderTest.java @@ -286,7 +286,9 @@ public TestAdsLoaderListener( 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 98% 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 29ef1faa80e..73bb49ed401 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,16 +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 { +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 7c8a454191f..4418549c8b2 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayer.java @@ -28,6 +28,7 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.text.TextRenderer; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; @@ -39,6 +40,7 @@ import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; +import java.util.List; /** * An extensible media player that plays {@link MediaSource}s. Instances can be obtained from {@link @@ -139,7 +141,7 @@ final class Builder { private LoadControl loadControl; private BandwidthMeter bandwidthMeter; private Looper looper; - private AnalyticsCollector analyticsCollector; + @Nullable private AnalyticsCollector analyticsCollector; private boolean useLazyPreparation; private boolean buildCalled; @@ -170,7 +172,7 @@ public Builder(Context context, Renderer... renderers) { new DefaultLoadControl(), DefaultBandwidthMeter.getSingletonInstance(context), Util.getLooper(), - new AnalyticsCollector(Clock.DEFAULT), + /* analyticsCollector= */ null, /* useLazyPreparation= */ true, Clock.DEFAULT); } @@ -197,7 +199,7 @@ public Builder( 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 ExoPlayer build() { 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(); + /** @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}. Equivalent to {@code - * prepare(mediaSource, true, true)}. + * Clears the playlist and adds the specified {@link MediaSource MediaSources}. + * + * @param mediaItems The new {@link MediaSource MediaSources}. */ - void prepare(MediaSource mediaSource); + void setMediaItems(List mediaItems); /** - * 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}. * @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 efe351c70ac..b900491b1d4 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 static SimpleExoPlayer newSimpleInstance( drmSessionManager, bandwidthMeter, analyticsCollector, + /* useLazyPreparation= */ true, Clock.DEFAULT, looper); } @@ -344,6 +345,13 @@ public static ExoPlayer newInstance( BandwidthMeter bandwidthMeter, Looper looper) { return new ExoPlayerImpl( - renderers, trackSelector, loadControl, bandwidthMeter, Clock.DEFAULT, looper); + renderers, + trackSelector, + loadControl, + bandwidthMeter, + /* analyticsCollector= */ null, + /* useLazyPreparation= */ true, + Clock.DEFAULT, + looper); } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index dd8fbee53cb..e0ac51495bf 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -22,8 +22,10 @@ import android.util.Pair; import androidx.annotation.Nullable; import com.google.android.exoplayer2.PlayerMessage.Target; +import com.google.android.exoplayer2.analytics.AnalyticsCollector; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; @@ -35,6 +37,9 @@ import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; /** @@ -61,19 +66,20 @@ 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 @@ * @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 @@ public ExoPlayerImpl( TrackSelector trackSelector, LoadControl loadControl, BandwidthMeter bandwidthMeter, + @Nullable AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" @@ -107,10 +119,13 @@ public ExoPlayerImpl( Assertions.checkState(renderers.length > 0); this.renderers = Assertions.checkNotNull(renderers); this.trackSelector = Assertions.checkNotNull(trackSelector); - this.playWhenReady = false; - this.repeatMode = Player.REPEAT_MODE_OFF; - this.shuffleModeEnabled = false; - this.listeners = new CopyOnWriteArrayList<>(); + this.useLazyPreparation = useLazyPreparation; + playWhenReady = false; + repeatMode = Player.REPEAT_MODE_OFF; + shuffleModeEnabled = false; + listeners = new CopyOnWriteArrayList<>(); + mediaSourceHolders = new ArrayList<>(); + shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); emptyTrackSelectorResult = new TrackSelectorResult( new RendererConfiguration[renderers.length], @@ -120,6 +135,7 @@ public ExoPlayerImpl( playbackParameters = PlaybackParameters.DEFAULT; seekParameters = SeekParameters.DEFAULT; playbackSuppressionReason = PLAYBACK_SUPPRESSION_REASON_NONE; + maskingWindowIndex = C.INDEX_UNSET; eventHandler = new Handler(looper) { @Override @@ -129,6 +145,9 @@ public void handleMessage(Message msg) { }; playbackInfo = PlaybackInfo.createDummy(/* startPositionUs= */ 0, emptyTrackSelectorResult); pendingListenerNotifications = new ArrayDeque<>(); + if (analyticsCollector != null) { + analyticsCollector.setPlayer(this); + } internalPlayer = new ExoPlayerImplInternal( renderers, @@ -139,6 +158,7 @@ public void handleMessage(Message msg) { playWhenReady, repeatMode, shuffleModeEnabled, + analyticsCollector, eventHandler, clock); internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); @@ -212,41 +232,162 @@ public ExoPlaybackException getPlaybackError() { } @Override + @Deprecated public void retry() { - if (mediaSource != null && playbackInfo.playbackState == Player.STATE_IDLE) { - prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); - } + prepare(); } @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; + public void prepare() { + if (playbackInfo.playbackState != Player.STATE_IDLE) { + return; + } PlaybackInfo playbackInfo = getResetPlaybackInfo( - resetPosition, - resetState, + /* clearPlaylist= */ false, /* resetError= */ true, - /* playbackState= */ Player.STATE_BUFFERING); + /* playbackState= */ this.playbackInfo.timeline.isEmpty() + ? Player.STATE_ENDED + : Player.STATE_BUFFERING); // Trigger internal prepare first before updating the playback info and notifying external // listeners to ensure that new operations issued in the listener notifications reach the // player after this prepare. The internal player can't change the playback info immediately // because it uses a callback. - 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) { + setMediaItemsInternal( + mediaItems, + /* startWindowIndex= */ C.INDEX_UNSET, + /* startPositionMs= */ C.TIME_UNSET, + /* resetToDefaultPosition= */ resetPosition); + } + + @Override + public void setMediaItems( + List mediaItems, int startWindowIndex, long startPositionMs) { + setMediaItemsInternal( + mediaItems, startWindowIndex, startPositionMs, /* resetToDefaultPosition= */ false); + } + + @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); + Timeline timeline = maskTimeline(); + internalPlayer.addMediaItems(index, holders, shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + @Override + public MediaSource removeMediaItem(int index) { + List mediaSourceHolders = + removeMediaItemsInternal(/* fromIndex= */ index, /* toIndex= */ index + 1); + return mediaSourceHolders.isEmpty() ? null : mediaSourceHolders.get(0).mediaSource; + } + + @Override + public void removeMediaItems(int fromIndex, int toIndex) { + Assertions.checkArgument(toIndex > fromIndex); + removeMediaItemsInternal(fromIndex, toIndex); + } + + @Override + public void moveMediaItem(int currentIndex, int newIndex) { + Assertions.checkArgument(currentIndex != newIndex); + moveMediaItems(/* fromIndex= */ currentIndex, /* toIndex= */ currentIndex + 1, newIndex); + } + + @Override + public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) { + Assertions.checkArgument( + fromIndex >= 0 + && fromIndex <= toIndex + && toIndex <= mediaSourceHolders.size() + && newFromIndex >= 0); + pendingOperationAcks++; + newFromIndex = Math.min(newFromIndex, mediaSourceHolders.size() - (toIndex - fromIndex)); + Playlist.moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex); + Timeline timeline = maskTimeline(); + internalPlayer.moveMediaItems(fromIndex, toIndex, newFromIndex, shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(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; + Timeline timeline = maskTimeline(); + internalPlayer.setShuffleOrder(shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } @Override public void setPlayWhenReady(boolean playWhenReady) { @@ -408,13 +549,9 @@ public void setForegroundMode(boolean foregroundMode) { @Override public void stop(boolean reset) { - if (reset) { - mediaSource = null; - } PlaybackInfo playbackInfo = getResetPlaybackInfo( - /* resetPosition= */ reset, - /* resetState= */ reset, + /* clearPlaylist= */ reset, /* resetError= */ reset, /* playbackState= */ Player.STATE_IDLE); // Trigger internal stop first before updating the playback info and notifying external @@ -427,7 +564,7 @@ public void stop(boolean reset) { playbackInfo, /* positionDiscontinuity= */ false, /* ignored */ DISCONTINUITY_REASON_INTERNAL, - TIMELINE_CHANGE_REASON_RESET, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, /* seekProcessed= */ false); } @@ -436,13 +573,11 @@ public void release() { 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); } @@ -468,12 +603,8 @@ public int getCurrentPeriodIndex() { @Override public int getCurrentWindowIndex() { - if (shouldMaskPosition()) { - return maskingWindowIndex; - } else { - return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) - .windowIndex; - } + int currentWindowIndex = getCurrentWindowIndexInternal(); + return currentWindowIndex == C.INDEX_UNSET ? 0 : currentWindowIndex; } @Override @@ -590,10 +721,11 @@ public Timeline getCurrentTimeline() { // Not private so it can be called from an inner class without going through a thunk method. /* package */ void handleEvent(Message msg) { + switch (msg.what) { case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED: handlePlaybackInfo( - (PlaybackInfo) msg.obj, + /* playbackInfo= */ (PlaybackInfo) msg.obj, /* operationAcks= */ msg.arg1, /* positionDiscontinuity= */ msg.arg2 != C.INDEX_UNSET, /* positionDiscontinuityReason= */ msg.arg2); @@ -606,6 +738,15 @@ public Timeline getCurrentTimeline() { } } + private int getCurrentWindowIndexInternal() { + if (shouldMaskPosition()) { + return maskingWindowIndex; + } else { + return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) + .windowIndex; + } + } + private void handlePlaybackParameters( PlaybackParameters playbackParameters, boolean operationAck) { if (operationAck) { @@ -638,33 +779,27 @@ private void handlePlaybackInfo( if (!this.playbackInfo.timeline.isEmpty() && playbackInfo.timeline.isEmpty()) { // Update the masking variables, which are used when the timeline becomes empty. maskingPeriodIndex = 0; - maskingWindowIndex = 0; + maskingWindowIndex = C.INDEX_UNSET; 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) { - maskingWindowIndex = 0; + boolean clearPlaylist, boolean resetError, @Player.State int playbackState) { + if (clearPlaylist) { + // Reset list of media source holders which are used for creating the masking timeline. + removeMediaSourceHolders( + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + maskingWindowIndex = C.INDEX_UNSET; maskingPeriodIndex = 0; maskingWindowPositionMs = 0; } else { @@ -672,24 +807,22 @@ private PlaybackInfo getResetPlaybackInfo( 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, @@ -699,8 +832,8 @@ private PlaybackInfo getResetPlaybackInfo( private void updatePlaybackInfo( PlaybackInfo playbackInfo, boolean positionDiscontinuity, - @Player.DiscontinuityReason int positionDiscontinuityReason, - @Player.TimelineChangeReason int timelineChangeReason, + @DiscontinuityReason int positionDiscontinuityReason, + @TimelineChangeReason int timelineChangeReason, boolean seekProcessed) { boolean previousIsPlaying = isPlaying(); // Assign playback info immediately such that all getters return the right values. @@ -721,6 +854,80 @@ private void updatePlaybackInfo( /* isPlayingChanged= */ previousIsPlaying != isPlaying)); } + private void setMediaItemsInternal( + List mediaItems, + int startWindowIndex, + long startPositionMs, + boolean resetToDefaultPosition) { + int currentWindowIndex = getCurrentWindowIndexInternal(); + long currentPositionMs = getCurrentPosition(); + pendingOperationAcks++; + if (!mediaSourceHolders.isEmpty()) { + removeMediaSourceHolders( + /* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size()); + } + List holders = addMediaSourceHolders(/* index= */ 0, mediaItems); + Timeline timeline = maskTimeline(); + if (resetToDefaultPosition) { + startWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + startPositionMs = C.TIME_UNSET; + } else if (startWindowIndex == C.INDEX_UNSET) { + startWindowIndex = currentWindowIndex; + startPositionMs = currentPositionMs; + } + internalPlayer.setMediaItems( + holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)); + } + + 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); + Timeline timeline = maskTimeline(); + internalPlayer.removeMediaItems(fromIndex, toIndex, shuffleOrder); + notifyListeners( + listener -> listener.onTimelineChanged(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 Timeline maskTimeline() { + playbackInfo = + playbackInfo.copyWithTimeline( + mediaSourceHolders.isEmpty() + ? Timeline.EMPTY + : new Playlist.PlaylistTimeline(mediaSourceHolders, shuffleOrder)); + return playbackInfo.timeline; + } + private void notifyListeners(ListenerInvocation listenerInvocation) { CopyOnWriteArrayList listenerSnapshot = new CopyOnWriteArrayList<>(listeners); notifyListeners(() -> invokeAll(listenerSnapshot, listenerInvocation)); @@ -756,7 +963,7 @@ private static final class PlaybackInfoUpdate implements Runnable { private final TrackSelector trackSelector; private final boolean positionDiscontinuity; private final @Player.DiscontinuityReason int positionDiscontinuityReason; - private final @Player.TimelineChangeReason int timelineChangeReason; + private final int timelineChangeReason; private final boolean seekProcessed; private final boolean playbackStateChanged; private final boolean playbackErrorChanged; @@ -790,15 +997,16 @@ public PlaybackInfoUpdate( 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 f5c4b40d550..8fae2674b9f 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.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 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 @@ implements Handler.Callback, MediaPeriod.Callback, TrackSelector.InvalidationListener, - MediaSourceCaller, + Playlist.PlaylistInfoRefreshListener, PlaybackParameterListener, PlayerMessage.Sender { @@ -71,16 +72,21 @@ private static final int MSG_SET_SEEK_PARAMETERS = 5; private static final int MSG_STOP = 6; private static final int MSG_RELEASE = 7; - private static final int MSG_REFRESH_SOURCE_INFO = 8; - private static final int MSG_PERIOD_PREPARED = 9; - private static final int MSG_SOURCE_CONTINUE_LOADING_REQUESTED = 10; - private static final int MSG_TRACK_SELECTION_INVALIDATED = 11; - private static final int MSG_SET_REPEAT_MODE = 12; - private static final int MSG_SET_SHUFFLE_ENABLED = 13; - private static final int MSG_SET_FOREGROUND_MODE = 14; - private static final int MSG_SEND_MESSAGE = 15; - private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 16; - private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 17; + private static final int MSG_PERIOD_PREPARED = 8; + private static final int MSG_SOURCE_CONTINUE_LOADING_REQUESTED = 9; + private static final int MSG_TRACK_SELECTION_INVALIDATED = 10; + private static final int MSG_SET_REPEAT_MODE = 11; + private static final int MSG_SET_SHUFFLE_ENABLED = 12; + private static final int MSG_SET_FOREGROUND_MODE = 13; + private static final int MSG_SEND_MESSAGE = 14; + private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 15; + private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 16; + private static final int MSG_SET_MEDIA_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 @@ 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; @@ -118,8 +124,7 @@ private boolean shuffleModeEnabled; private boolean foregroundMode; - private int pendingPrepareCount; - private SeekPosition pendingInitialSeekPosition; + @Nullable private SeekPosition pendingInitialSeekPosition; private long rendererPositionUs; private int nextPendingMessageIndex; @@ -132,6 +137,7 @@ public ExoPlayerImplInternal( boolean playWhenReady, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled, + @Nullable AnalyticsCollector analyticsCollector, Handler eventHandler, Clock clock) { this.renderers = renderers; @@ -171,12 +177,14 @@ public ExoPlayerImplInternal( 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) { @@ -209,6 +217,48 @@ public void stop(boolean reset) { handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); } + 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( + 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 || !internalPlaybackThread.isAlive()) { @@ -268,13 +318,11 @@ public Looper getPlaybackLooper() { return internalPlaybackThread.getLooper(); } - // MediaSource.MediaSourceCaller implementation. + // Playlist.PlaylistInfoRefreshListener implementation. @Override - public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { - handler - .obtainMessage(MSG_REFRESH_SOURCE_INFO, new MediaSourceRefreshInfo(source, timeline)) - .sendToTarget(); + public void onPlaylistUpdateRequested() { + handler.sendEmptyMessage(MSG_PLAYLIST_UPDATE_REQUESTED); } // MediaPeriod.Callback implementation. @@ -306,14 +354,12 @@ public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { // Handler.Callback implementation. @Override + @SuppressWarnings("unchecked") public boolean handleMessage(Message msg) { try { switch (msg.what) { case MSG_PREPARE: - prepareInternal( - (MediaSource) msg.obj, - /* resetPosition= */ msg.arg1 != 0, - /* resetState= */ msg.arg2 != 0); + prepareInternal(); break; case MSG_SET_PLAY_WHEN_READY: setPlayWhenReadyInternal(msg.arg1 != 0); @@ -349,9 +395,6 @@ public boolean handleMessage(Message msg) { case MSG_PERIOD_PREPARED: handlePeriodPrepared((MediaPeriod) msg.obj); break; - case MSG_REFRESH_SOURCE_INFO: - handleSourceInfoRefreshed((MediaSourceRefreshInfo) msg.obj); - break; case MSG_SOURCE_CONTINUE_LOADING_REQUESTED: handleContinueLoadingRequested((MediaPeriod) msg.obj); break; @@ -368,6 +411,24 @@ public boolean handleMessage(Message msg) { 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. @@ -431,21 +492,77 @@ private void maybeNotifyPlaybackInfoChanged() { } } - private void prepareInternal(MediaSource mediaSource, boolean resetPosition, boolean resetState) { - pendingPrepareCount++; + private void prepareInternal() { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ true, - resetPosition, - resetState, + /* resetPosition= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); loadControl.onPrepared(); - this.mediaSource = mediaSource; - setState(Player.STATE_BUFFERING); - mediaSource.prepareSource(/* caller= */ this, bandwidthMeter.getTransferListener()); + setState(playbackInfo.timeline.isEmpty() ? Player.STATE_ENDED : Player.STATE_BUFFERING); + playlist.prepare(bandwidthMeter.getTransferListener()); handler.sendEmptyMessage(MSG_DO_SOME_WORK); } + private void setMediaItemsInternal(PlaylistUpdateMessage playlistUpdateMessage) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + if (playlistUpdateMessage.windowIndex != C.INDEX_UNSET) { + pendingInitialSeekPosition = + new SeekPosition( + new Playlist.PlaylistTimeline( + playlistUpdateMessage.mediaSourceHolders, playlistUpdateMessage.shuffleOrder), + playlistUpdateMessage.windowIndex, + playlistUpdateMessage.positionUs); + } + Timeline timeline = + playlist.setMediaSources( + playlistUpdateMessage.mediaSourceHolders, playlistUpdateMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void addMediaItemsInternal(PlaylistUpdateMessage addMessage, int insertionIndex) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = + playlist.addMediaSources( + insertionIndex == C.INDEX_UNSET ? playlist.getSize() : insertionIndex, + addMessage.mediaSourceHolders, + addMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void moveMediaItemsInternal(MoveMediaItemsMessage moveMediaItemsMessage) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = + playlist.moveMediaSourceRange( + moveMediaItemsMessage.fromIndex, + moveMediaItemsMessage.toIndex, + moveMediaItemsMessage.newFromIndex, + moveMediaItemsMessage.shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void removeMediaItemsInternal(int fromIndex, int toIndex, ShuffleOrder shuffleOrder) + throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = playlist.removeMediaSourceRange(fromIndex, toIndex, shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + + private void playlistUpdateRequestedInternal() throws ExoPlaybackException { + handlePlaylistInfoRefreshed(playlist.createTimeline()); + } + + private void setShuffleOrderInternal(ShuffleOrder shuffleOrder) throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + Timeline timeline = playlist.setShuffleOrder(shuffleOrder); + handlePlaylistInfoRefreshed(timeline); + } + private void setPlayWhenReadyInternal(boolean playWhenReady) throws ExoPlaybackException { rebuffering = false; this.playWhenReady = playWhenReady; @@ -663,6 +780,7 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti long periodPositionUs; long contentPositionUs; boolean seekPositionAdjusted; + @Nullable Pair resolvedSeekPosition = resolveSeekPosition(seekPosition, /* trySubsequentPeriods= */ true); if (resolvedSeekPosition == null) { @@ -687,7 +805,7 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti } try { - if (mediaSource == null || pendingPrepareCount > 0) { + if (playbackInfo.timeline.isEmpty() || !playlist.isPrepared()) { // Save seek position for later, as we are still waiting for a prepared source. pendingInitialSeekPosition = seekPosition; } else if (periodPositionUs == C.TIME_UNSET) { @@ -695,9 +813,9 @@ private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackExcepti setState(Player.STATE_ENDED); resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ false, /* resetPosition= */ true, - /* resetState= */ false, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); } else { // Execute the seek in the current media periods. @@ -844,13 +962,11 @@ private void stopInternal( boolean forceResetRenderers, boolean resetPositionAndState, boolean acknowledgeStop) { resetInternal( /* resetRenderers= */ forceResetRenderers || !foregroundMode, - /* releaseMediaSource= */ true, /* resetPosition= */ resetPositionAndState, - /* resetState= */ resetPositionAndState, + /* releasePlaylist= */ true, + /* clearPlaylist= */ resetPositionAndState, /* resetError= */ resetPositionAndState); - playbackInfoUpdate.incrementPendingOperationAcks( - pendingPrepareCount + (acknowledgeStop ? 1 : 0)); - pendingPrepareCount = 0; + playbackInfoUpdate.incrementPendingOperationAcks(acknowledgeStop ? 1 : 0); loadControl.onStopped(); setState(Player.STATE_IDLE); } @@ -858,9 +974,9 @@ private void stopInternal( private void releaseInternal() { resetInternal( /* resetRenderers= */ true, - /* releaseMediaSource= */ true, /* resetPosition= */ true, - /* resetState= */ true, + /* releasePlaylist= */ true, + /* clearPlaylist= */ true, /* resetError= */ false); loadControl.onReleased(); setState(Player.STATE_IDLE); @@ -873,9 +989,9 @@ private void releaseInternal() { private void resetInternal( boolean resetRenderers, - boolean releaseMediaSource, boolean resetPosition, - boolean resetState, + boolean releasePlaylist, + boolean clearPlaylist, boolean resetError) { handler.removeMessages(MSG_DO_SOME_WORK); rebuffering = false; @@ -903,8 +1019,8 @@ private void resetInternal( if (resetPosition) { pendingInitialSeekPosition = null; - } else if (resetState) { - // When resetting the state, also reset the period-based PlaybackInfo position and convert + } else if (clearPlaylist) { + // When clearing the playlist, also reset the period-based PlaybackInfo position and convert // existing position to initial seek instead. resetPosition = true; if (pendingInitialSeekPosition == null && !playbackInfo.timeline.isEmpty()) { @@ -915,10 +1031,10 @@ private void resetInternal( } } - queue.clear(/* keepFrontPeriodUid= */ !resetState); + queue.clear(/* keepFrontPeriodUid= */ !clearPlaylist); shouldContinueLoading = false; - if (resetState) { - queue.setTimeline(Timeline.EMPTY); + if (clearPlaylist) { + queue.setTimeline(playlist.clear(/* shuffleOrder= */ null)); for (PendingMessageInfo pendingMessageInfo : pendingMessages) { pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); } @@ -934,24 +1050,21 @@ private void resetInternal( 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(); } } @@ -959,7 +1072,7 @@ private void sendMessageInternal(PlayerMessage message) throws ExoPlaybackExcept 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 { @@ -1279,20 +1392,11 @@ private void maybeThrowSourceInfoRefreshError() throws IOException { } } } - mediaSource.maybeThrowSourceInfoRefreshError(); + playlist.maybeThrowSourceInfoRefreshError(); } - private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) - throws ExoPlaybackException { - if (sourceRefreshInfo.source != mediaSource) { - // Stale event. - return; - } - playbackInfoUpdate.incrementPendingOperationAcks(pendingPrepareCount); - pendingPrepareCount = 0; - + private void handlePlaylistInfoRefreshed(Timeline timeline) throws ExoPlaybackException { Timeline oldTimeline = playbackInfo.timeline; - Timeline timeline = sourceRefreshInfo.timeline; queue.setTimeline(timeline); playbackInfo = playbackInfo.copyWithTimeline(timeline); resolvePendingMessagePositions(); @@ -1303,13 +1407,15 @@ private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) long newContentPositionUs = oldContentPositionUs; if (pendingInitialSeekPosition != null) { // Resolve initial seek position. + @Nullable Pair periodPosition = resolveSeekPosition(pendingInitialSeekPosition, /* trySubsequentPeriods= */ true); - pendingInitialSeekPosition = null; + boolean keepInitialSeekPosition = periodPosition == null && timeline.isEmpty(); + pendingInitialSeekPosition = keepInitialSeekPosition ? pendingInitialSeekPosition : null; if (periodPosition == null) { // The seek position was valid for the timeline that it was performed into, but the // timeline has changed and a suitable seek position could not be resolved in the new one. - handleSourceInfoRefreshEndedPlayback(); + handleSourceInfoRefreshEndedPlayback(/* resetPosition= */ !keepInitialSeekPosition); return; } newContentPositionUs = periodPosition.second; @@ -1330,7 +1436,7 @@ private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) Object newPeriodUid = resolveSubsequentPeriod(newPeriodId.periodUid, oldTimeline, timeline); if (newPeriodUid == null) { // We failed to resolve a suitable restart position. - handleSourceInfoRefreshEndedPlayback(); + handleSourceInfoRefreshEndedPlayback(/* resetPosition= */ true); return; } // We resolved a subsequent period. Start at the default position in the corresponding window. @@ -1403,16 +1509,16 @@ private long getMaxRendererReadPositionUs() { return maxReadPositionUs; } - private void handleSourceInfoRefreshEndedPlayback() { + private void handleSourceInfoRefreshEndedPlayback(boolean resetPosition) { if (playbackInfo.playbackState != Player.STATE_IDLE) { setState(Player.STATE_ENDED); } - // Reset, but retain the source so that it can still be used should a seek occur. + // Reset, but retain the playlist so that it can still be used should a seek occur. resetInternal( /* resetRenderers= */ false, - /* releaseMediaSource= */ false, - /* resetPosition= */ true, - /* resetState= */ false, + /* resetPosition= */ resetPosition, + /* releasePlaylist= */ false, + /* clearPlaylist= */ false, /* resetError= */ true); } @@ -1455,6 +1561,7 @@ private void handleSourceInfoRefreshEndedPlayback() { * @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; @@ -1511,13 +1618,9 @@ private Pair getPeriodPosition( } private void updatePeriods() throws ExoPlaybackException, IOException { - if (mediaSource == null) { - // The player has no media source yet. - return; - } - if (pendingPrepareCount > 0) { + if (playbackInfo.timeline.isEmpty() || !playlist.isPrepared()) { // We're waiting to get information about periods. - mediaSource.maybeThrowSourceInfoRefreshError(); + playlist.maybeThrowSourceInfoRefreshError(); return; } maybeUpdateLoadingPeriod(); @@ -1537,7 +1640,7 @@ private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException rendererCapabilities, trackSelector, loadControl.getAllocator(), - mediaSource, + playlist, info, emptyTrackSelectorResult); mediaPeriodHolder.mediaPeriod.prepare(this, info.startPositionUs); @@ -1556,7 +1659,7 @@ private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException } private void maybeUpdateReadingPeriod() throws ExoPlaybackException { - MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + @Nullable MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); if (readingPeriodHolder == null) { return; } @@ -1986,14 +2089,38 @@ public int compareTo(@NonNull PendingMessageInfo other) { } } - private static final class MediaSourceRefreshInfo { + private static final class PlaylistUpdateMessage { - public final MediaSource source; - public final Timeline timeline; + private final List mediaSourceHolders; + private final ShuffleOrder shuffleOrder; + private final int windowIndex; + private final long positionUs; - public MediaSourceRefreshInfo(MediaSource source, Timeline timeline) { - this.source = source; - this.timeline = timeline; + private PlaylistUpdateMessage( + List mediaSourceHolders, + ShuffleOrder shuffleOrder, + int windowIndex, + long positionUs) { + this.mediaSourceHolders = mediaSourceHolders; + this.shuffleOrder = shuffleOrder; + this.windowIndex = windowIndex; + this.positionUs = positionUs; + } + } + + private static class MoveMediaItemsMessage { + + public final int fromIndex; + public final int toIndex; + public final int newFromIndex; + public final ShuffleOrder shuffleOrder; + + public MoveMediaItemsMessage( + int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) { + this.fromIndex = fromIndex; + this.toIndex = toIndex; + this.newFromIndex = newFromIndex; + this.shuffleOrder = shuffleOrder; } } @@ -2002,7 +2129,7 @@ private static final class PlaybackInfoUpdate { private PlaybackInfo lastPlaybackInfo; private int operationAcks; private boolean positionDiscontinuity; - private @DiscontinuityReason int discontinuityReason; + @DiscontinuityReason private int discontinuityReason; public boolean hasPendingUpdate(PlaybackInfo playbackInfo) { return playbackInfo != lastPlaybackInfo || operationAcks > 0 || positionDiscontinuity; @@ -2030,5 +2157,4 @@ public void setPositionDiscontinuity(@DiscontinuityReason int discontinuityReaso this.discontinuityReason = discontinuityReason; } } - } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java index 850d2b7d108..5bbbcbea2a9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -19,7 +19,6 @@ import com.google.android.exoplayer2.source.ClippingMediaPeriod; import com.google.android.exoplayer2.source.EmptySampleStream; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.SampleStream; import com.google.android.exoplayer2.source.TrackGroupArray; @@ -56,7 +55,7 @@ private final boolean[] mayRetainStreamFlags; private final RendererCapabilities[] rendererCapabilities; private final TrackSelector trackSelector; - private final MediaSource mediaSource; + private final Playlist playlist; @Nullable private MediaPeriodHolder next; private TrackGroupArray trackGroups; @@ -70,7 +69,7 @@ * @param rendererPositionOffsetUs The renderer time of the start of the period, in microseconds. * @param trackSelector The track selector. * @param allocator The allocator. - * @param mediaSource The media source that produced the media period. + * @param playlist The playlist. * @param info Information used to identify this media period in its timeline period. * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each * renderer. @@ -80,13 +79,13 @@ public MediaPeriodHolder( long rendererPositionOffsetUs, TrackSelector trackSelector, Allocator allocator, - MediaSource mediaSource, + Playlist playlist, MediaPeriodInfo info, TrackSelectorResult emptyTrackSelectorResult) { this.rendererCapabilities = rendererCapabilities; this.rendererPositionOffsetUs = rendererPositionOffsetUs; this.trackSelector = trackSelector; - this.mediaSource = mediaSource; + this.playlist = playlist; this.uid = info.id.periodUid; this.info = info; this.trackGroups = TrackGroupArray.EMPTY; @@ -94,8 +93,7 @@ public MediaPeriodHolder( sampleStreams = new SampleStream[rendererCapabilities.length]; mayRetainStreamFlags = new boolean[rendererCapabilities.length]; mediaPeriod = - createMediaPeriod( - info.id, mediaSource, allocator, info.startPositionUs, info.endPositionUs); + createMediaPeriod(info.id, playlist, allocator, info.startPositionUs, info.endPositionUs); } /** @@ -305,7 +303,7 @@ public long applyTrackSelection( /** Releases the media period. No other method should be called after the release. */ public void release() { disableTrackSelectionsInResult(); - releaseMediaPeriod(info.endPositionUs, mediaSource, mediaPeriod); + releaseMediaPeriod(info.endPositionUs, playlist, mediaPeriod); } /** @@ -402,11 +400,11 @@ private boolean isLoadingMediaPeriod() { /** Returns a media period corresponding to the given {@code id}. */ private static MediaPeriod createMediaPeriod( MediaPeriodId id, - MediaSource mediaSource, + Playlist playlist, Allocator allocator, long startPositionUs, long endPositionUs) { - MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator, startPositionUs); + MediaPeriod mediaPeriod = playlist.createPeriod(id, allocator, startPositionUs); if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { mediaPeriod = new ClippingMediaPeriod( @@ -417,12 +415,12 @@ private static MediaPeriod createMediaPeriod( /** Releases the given {@code mediaPeriod}, logging and suppressing any errors. */ private static void releaseMediaPeriod( - long endPositionUs, MediaSource mediaSource, MediaPeriod mediaPeriod) { + long endPositionUs, Playlist playlist, MediaPeriod mediaPeriod) { try { if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { - mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); + playlist.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); } else { - mediaSource.releasePeriod(mediaPeriod); + playlist.releasePeriod(mediaPeriod); } } catch (RuntimeException e) { // There's nothing we can do. diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java index 901b7b4d94b..5b39db54aa3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -19,7 +19,6 @@ import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.source.MediaPeriod; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; @@ -134,7 +133,7 @@ public boolean shouldLoadNextMediaPeriod() { * @param rendererCapabilities The renderer capabilities. * @param trackSelector The track selector. * @param allocator The allocator. - * @param mediaSource The media source that produced the media period. + * @param playlist The playlist. * @param info Information used to identify this media period in its timeline period. * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each * renderer. @@ -143,7 +142,7 @@ public MediaPeriodHolder enqueueNextMediaPeriodHolder( RendererCapabilities[] rendererCapabilities, TrackSelector trackSelector, Allocator allocator, - MediaSource mediaSource, + Playlist playlist, MediaPeriodInfo info, TrackSelectorResult emptyTrackSelectorResult) { long rendererPositionOffsetUs = @@ -158,7 +157,7 @@ public MediaPeriodHolder enqueueNextMediaPeriodHolder( rendererPositionOffsetUs, trackSelector, allocator, - mediaSource, + playlist, info, emptyTrackSelectorResult); if (loading != null) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Player.java b/library/core/src/main/java/com/google/android/exoplayer2/Player.java index fba4676eaf7..810a448b33c 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 @@ -364,7 +364,8 @@ default void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reas * {@link #onPositionDiscontinuity(int)}. * * @param timeline The latest timeline. Never null, but may be empty. - * @param manifest The latest manifest. May be null. + * @param manifest The latest manifest in case the timeline has a single window only. Always + * null if the timeline has more than a single window. * @param reason The {@link TimelineChangeReason} responsible for this timeline change. * @deprecated Use {@link #onTimelineChanged(Timeline, int)} instead. The manifest can be * accessed by using {@link #getCurrentManifest()} or {@code timeline.getWindow(windowIndex, @@ -603,25 +604,17 @@ public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { int DISCONTINUITY_REASON_INTERNAL = 4; /** - * Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PREPARED}, {@link - * #TIMELINE_CHANGE_REASON_RESET} or {@link #TIMELINE_CHANGE_REASON_DYNAMIC}. + * Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED} or {@link + * #TIMELINE_CHANGE_REASON_SOURCE_UPDATE}. */ @Documented @Retention(RetentionPolicy.SOURCE) - @IntDef({ - TIMELINE_CHANGE_REASON_PREPARED, - TIMELINE_CHANGE_REASON_RESET, - TIMELINE_CHANGE_REASON_DYNAMIC - }) + @IntDef({TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, TIMELINE_CHANGE_REASON_SOURCE_UPDATE}) @interface TimelineChangeReason {} - /** Timeline and manifest changed as a result of a player initialization with new media. */ - int TIMELINE_CHANGE_REASON_PREPARED = 0; - /** Timeline and manifest changed as a result of a player reset. */ - int TIMELINE_CHANGE_REASON_RESET = 1; - /** - * Timeline or manifest changed as a result of an dynamic update introduced by the played media. - */ - int TIMELINE_CHANGE_REASON_DYNAMIC = 2; + /** Timeline changed as a result of a change of the playlist items or the order of the items. */ + int TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED = 0; + /** Timeline changed as a result of a dynamic update introduced by the played media. */ + int TIMELINE_CHANGE_REASON_SOURCE_UPDATE = 1; /** Returns the component of this player for audio output, or null if audio is not supported. */ @Nullable diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java new file mode 100644 index 00000000000..351c9d57800 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java @@ -0,0 +1,707 @@ +/* + * 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); + if (mediaSourceByMediaPeriod.isEmpty()) { + enabledMediaSourceHolders.add(holder); + } else { + disableChildSource(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); + enabledMediaSourceHolders.add(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()) { + disableChildSource(holder); + iterator.remove(); + } + } + } + + private void disableChildSource(MediaSourceHolder holder) { + @Nullable MediaSourceAndListener disabledChild = childSources.get(holder); + if (disabledChild != null) { + disabledChild.mediaSource.disable(disabledChild.caller); + } + } + + 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 1f7802ac72b..189750762f9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -42,6 +42,7 @@ import com.google.android.exoplayer2.metadata.Metadata; import com.google.android.exoplayer2.metadata.MetadataOutput; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.text.TextOutput; @@ -163,7 +164,9 @@ public Builder(Context context, RenderersFactory renderersFactory) { * @param bandwidthMeter A {@link BandwidthMeter}. * @param looper A {@link Looper} that must be used for all calls to the player. * @param analyticsCollector An {@link AnalyticsCollector}. - * @param useLazyPreparation Whether media sources should be initialized lazily. + * @param useLazyPreparation Whether playlist items should be prepared lazily. If false, all + * initial preparation steps (e.g., manifest loads) happen immediately. If true, these + * initial preparations are triggered only when the player starts buffering the media. * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. */ public Builder( @@ -300,6 +303,7 @@ public SimpleExoPlayer build() { loadControl, bandwidthMeter, analyticsCollector, + useLazyPreparation, clock, looper); } @@ -341,7 +345,6 @@ public SimpleExoPlayer build() { private int audioSessionId; private AudioAttributes audioAttributes; private float audioVolume; - @Nullable private MediaSource mediaSource; private List currentCues; @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; @Nullable private CameraMotionListener cameraMotionListener; @@ -357,6 +360,9 @@ public SimpleExoPlayer build() { * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. * @param analyticsCollector A factory for creating the {@link AnalyticsCollector} that will * collect and forward all player events. + * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest + * loads and other initial preparation steps happen immediately. If true, these initial + * preparations are triggered only when the player starts buffering the media. * @param clock The {@link Clock} that will be used by the instance. Should always be {@link * Clock#DEFAULT}, unless the player is being used from a test. * @param looper The {@link Looper} which must be used for all calls to the player and which is @@ -370,6 +376,7 @@ protected SimpleExoPlayer( LoadControl loadControl, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this( @@ -380,26 +387,14 @@ protected SimpleExoPlayer( DrmSessionManager.getDummyDrmSessionManager(), bandwidthMeter, analyticsCollector, + useLazyPreparation, clock, looper); } /** - * @param context A {@link Context}. - * @param renderersFactory A factory for creating {@link Renderer}s to be used by the instance. - * @param trackSelector The {@link TrackSelector} that will be used by the instance. - * @param loadControl The {@link LoadControl} that will be used by the instance. - * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance - * will not be used for DRM protected playbacks. - * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. - * @param analyticsCollector The {@link AnalyticsCollector} that will collect and forward all - * player events. - * @param clock The {@link Clock} that will be used by the instance. Should always be {@link - * Clock#DEFAULT}, unless the player is being used from a test. - * @param looper The {@link Looper} which must be used for all calls to the player and which is - * used to call listeners on. * @deprecated Use {@link #SimpleExoPlayer(Context, RenderersFactory, TrackSelector, LoadControl, - * BandwidthMeter, AnalyticsCollector, Clock, Looper)} instead, and pass the {@link + * BandwidthMeter, AnalyticsCollector, boolean, Clock, Looper)} instead, and pass the {@link * DrmSessionManager} to the {@link MediaSource} factories. */ @Deprecated @@ -411,6 +406,7 @@ protected SimpleExoPlayer( @Nullable DrmSessionManager drmSessionManager, BandwidthMeter bandwidthMeter, AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, Clock clock, Looper looper) { this.bandwidthMeter = bandwidthMeter; @@ -441,7 +437,15 @@ protected SimpleExoPlayer( // Build the player and associated objects. player = - new ExoPlayerImpl(renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + new ExoPlayerImpl( + renderers, + trackSelector, + loadControl, + bandwidthMeter, + analyticsCollector, + useLazyPreparation, + clock, + looper); analyticsCollector.setPlayer(player); addListener(analyticsCollector); addListener(componentListener); @@ -1130,32 +1134,133 @@ public ExoPlaybackException getPlaybackError() { } @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 @@ -1235,6 +1340,7 @@ public SeekParameters getSeekParameters() { @Override public void setForegroundMode(boolean foregroundMode) { + verifyApplicationThread(); player.setForegroundMode(foregroundMode); } @@ -1242,13 +1348,6 @@ public void setForegroundMode(boolean foregroundMode) { public void stop(boolean reset) { verifyApplicationThread(); player.stop(reset); - if (mediaSource != null) { - mediaSource.removeEventListener(analyticsCollector); - analyticsCollector.resetForNewMediaSource(); - if (reset) { - mediaSource = null; - } - } audioFocusManager.handleStop(); currentCues = Collections.emptyList(); } @@ -1266,10 +1365,6 @@ public void release() { } surface = null; } - if (mediaSource != null) { - mediaSource.removeEventListener(analyticsCollector); - mediaSource = null; - } if (isPriorityTaskManagerRegistered) { Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); isPriorityTaskManagerRegistered = false; 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 ce1a58822c4..a860249478a 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 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 @@ -278,6 +279,48 @@ public long getPositionInFirstPeriodUs() { 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 + && isLive == that.isLive + && 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 + (isLive ? 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; + } } /** @@ -534,6 +577,34 @@ public long getAdResumePositionUs() { 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 5a57844c835..db473281468 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 @@ -133,11 +133,8 @@ public final void notifySeekStarted() { } } - /** - * Resets the analytics collector for a new media source. Should be called before the player is - * prepared with a new media source. - */ - public final void resetForNewMediaSource() { + /** Resets the analytics collector for a new playlist. */ + public final void resetForNewPlaylist() { // Copying the list is needed because onMediaPeriodReleased will modify the list. List mediaPeriodInfos = new ArrayList<>(mediaPeriodQueueTracker.mediaPeriodInfoQueue); @@ -804,9 +801,13 @@ public void onSeekProcessed() { /** Updates the queue with a newly created media period. */ public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { - boolean isInTimeline = timeline.getIndexOfPeriod(mediaPeriodId.periodUid) != C.INDEX_UNSET; + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + boolean isInTimeline = periodIndex != C.INDEX_UNSET; MediaPeriodInfo mediaPeriodInfo = - new MediaPeriodInfo(mediaPeriodId, isInTimeline ? timeline : Timeline.EMPTY, windowIndex); + new MediaPeriodInfo( + mediaPeriodId, + isInTimeline ? timeline : Timeline.EMPTY, + isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); mediaPeriodInfoQueue.add(mediaPeriodInfo); mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); @@ -822,7 +823,7 @@ public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { public boolean onMediaPeriodReleased(MediaPeriodId mediaPeriodId) { MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.remove(mediaPeriodId); if (mediaPeriodInfo == null) { - // The media period has already been removed from the queue in resetForNewMediaSource(). + // The media period has already been removed from the queue in resetForNewPlaylist(). return false; } mediaPeriodInfoQueue.remove(mediaPeriodInfo); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java index 8dfea1e5116..c1ab78a9bc6 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.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; @@ -139,6 +140,23 @@ public ConcatenatingMediaSource( addMediaSources(Arrays.asList(mediaSources)); } + @Override + public synchronized Timeline getInitialTimeline() { + ShuffleOrder shuffleOrder = + this.shuffleOrder.getLength() != mediaSourcesPublic.size() + ? this.shuffleOrder + .cloneAndClear() + .cloneAndInsert( + /* insertionIndex= */ 0, /* insertionCount= */ mediaSourcesPublic.size()) + : this.shuffleOrder; + return new ConcatenatedTimeline(mediaSourcesPublic, shuffleOrder, isAtomic); + } + + @Override + public boolean isSingleWindow() { + return false; + } + /** * Appends a {@link MediaSource} to the playlist. * 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 ac23e2a8317..68bed250e86 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 891cb351c1e..ad415fbd1bb 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 @@ 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; @@ -43,6 +44,7 @@ public final class MaskingMediaSource extends CompositeMediaSource { @Nullable private EventDispatcher unpreparedMaskingMediaPeriodEventDispatcher; private boolean hasStartedPreparing; private boolean isPrepared; + private boolean hasRealTimeline; /** * Creates the masking media source. @@ -54,14 +56,22 @@ public final class MaskingMediaSource extends CompositeMediaSource { */ public MaskingMediaSource(MediaSource mediaSource, boolean useLazyPreparation) { this.mediaSource = mediaSource; - this.useLazyPreparation = useLazyPreparation; + this.useLazyPreparation = useLazyPreparation && mediaSource.isSingleWindow(); window = new Timeline.Window(); period = new Timeline.Period(); - timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + Timeline initialTimeline = mediaSource.getInitialTimeline(); + if (initialTimeline != null) { + timeline = + MaskingTimeline.createWithRealTimeline( + initialTimeline, /* firstWindowUid= */ null, /* firstPeriodUid= */ null); + hasRealTimeline = true; + } else { + timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + } } /** Returns the {@link Timeline}. */ - public Timeline getTimeline() { + public synchronized Timeline getTimeline() { return timeline; } @@ -129,14 +139,16 @@ public void releaseSourceInternal() { } @Override - protected void onChildSourceInfoRefreshed( + protected synchronized void onChildSourceInfoRefreshed( Void id, MediaSource mediaSource, Timeline newTimeline) { if (isPrepared) { timeline = timeline.cloneWithUpdatedTimeline(newTimeline); } else if (newTimeline.isEmpty()) { timeline = - MaskingTimeline.createWithRealTimeline( - newTimeline, Window.SINGLE_WINDOW_UID, MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID); + hasRealTimeline + ? timeline.cloneWithUpdatedTimeline(newTimeline) + : MaskingTimeline.createWithRealTimeline( + newTimeline, Window.SINGLE_WINDOW_UID, MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID); } else { // Determine first period and the start position. // This will be: @@ -164,7 +176,10 @@ protected void onChildSourceInfoRefreshed( window, period, /* windowIndex= */ 0, windowStartPositionUs); Object periodUid = periodPosition.first; long periodPositionUs = periodPosition.second; - timeline = MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid); + timeline = + hasRealTimeline + ? timeline.cloneWithUpdatedTimeline(newTimeline) + : MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid); if (unpreparedMaskingMediaPeriod != null) { MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod; maskingPeriod.overridePreparePositionUs(periodPositionUs); @@ -173,6 +188,7 @@ protected void onChildSourceInfoRefreshed( maskingPeriod.createPeriod(idInSource); } } + hasRealTimeline = true; isPrepared = true; refreshSourceInfo(this.timeline); } @@ -193,13 +209,15 @@ protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId } private Object getInternalPeriodUid(Object externalPeriodUid) { - return externalPeriodUid.equals(MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID) + return timeline.replacedInternalPeriodUid != null + && externalPeriodUid.equals(MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID) ? timeline.replacedInternalPeriodUid : externalPeriodUid; } private Object getExternalPeriodUid(Object internalPeriodUid) { - return timeline.replacedInternalPeriodUid.equals(internalPeriodUid) + return timeline.replacedInternalPeriodUid != null + && timeline.replacedInternalPeriodUid.equals(internalPeriodUid) ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID : internalPeriodUid; } @@ -212,8 +230,8 @@ private static final class MaskingTimeline extends ForwardingTimeline { public static final Object DUMMY_EXTERNAL_PERIOD_UID = new Object(); - private final Object replacedInternalWindowUid; - private final Object replacedInternalPeriodUid; + @Nullable private final Object replacedInternalWindowUid; + @Nullable private final Object replacedInternalPeriodUid; /** * Returns an instance with a dummy timeline using the provided window tag. @@ -236,12 +254,14 @@ public static MaskingTimeline createWithDummyTimeline(@Nullable Object windowTag * assigned {@link #DUMMY_EXTERNAL_PERIOD_UID}. */ public static MaskingTimeline createWithRealTimeline( - Timeline timeline, Object firstWindowUid, Object firstPeriodUid) { + Timeline timeline, @Nullable Object firstWindowUid, @Nullable Object firstPeriodUid) { return new MaskingTimeline(timeline, firstWindowUid, firstPeriodUid); } private MaskingTimeline( - Timeline timeline, Object replacedInternalWindowUid, Object replacedInternalPeriodUid) { + Timeline timeline, + @Nullable Object replacedInternalWindowUid, + @Nullable Object replacedInternalPeriodUid) { super(timeline); this.replacedInternalWindowUid = replacedInternalWindowUid; this.replacedInternalPeriodUid = replacedInternalPeriodUid; @@ -282,7 +302,9 @@ public Period getPeriod(int periodIndex, Period period, boolean setIds) { @Override public int getIndexOfPeriod(Object uid) { return timeline.getIndexOfPeriod( - DUMMY_EXTERNAL_PERIOD_UID.equals(uid) ? replacedInternalPeriodUid : uid); + DUMMY_EXTERNAL_PERIOD_UID.equals(uid) && replacedInternalPeriodUid != null + ? replacedInternalPeriodUid + : uid); } @Override @@ -293,7 +315,8 @@ public Object getUidOfPeriod(int periodIndex) { } /** 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; @@ -333,8 +356,8 @@ public int getPeriodCount() { @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/source/MediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java index 5ee980d01f7..f6dd4d79a4c 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaSource.java @@ -228,6 +228,33 @@ public int hashCode() { */ void removeEventListener(MediaSourceEventListener eventListener); + /** + * Returns the initial dummy timeline that is returned immediately when the real timeline is not + * yet known, or null to let the player create an initial timeline. + * + *

The initial timeline must use the same uids for windows and periods that the real timeline + * will use. It also must provide windows which are marked as dynamic to indicate that the window + * is expected to change when the real timeline arrives. + * + *

Any media source which has multiple windows should typically provide such an initial + * timeline to make sure the player reports the correct number of windows immediately. + */ + @Nullable + default Timeline getInitialTimeline() { + return null; + } + + /** + * Returns true if the media source is guaranteed to never have zero or more than one window. + * + *

The default implementation returns {@code true}. + * + * @return true if the source has exactly one window. + */ + default boolean isSingleWindow() { + return true; + } + /** Returns the tag set on the media source, or null if none was set. */ @Nullable default Object getTag() { 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 a4e8e311ca2..f538f8e01d0 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 @@ -630,12 +630,10 @@ private static String getDiscontinuityReasonString(@Player.DiscontinuityReason i private static String getTimelineChangeReasonString(@Player.TimelineChangeReason int reason) { switch (reason) { - case Player.TIMELINE_CHANGE_REASON_PREPARED: - return "PREPARED"; - case Player.TIMELINE_CHANGE_REASON_RESET: - return "RESET"; - case Player.TIMELINE_CHANGE_REASON_DYNAMIC: - return "DYNAMIC"; + case Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE: + return "SOURCE_UPDATE"; + case Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED: + return "PLAYLIST_CHANGED"; default: return "?"; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java b/library/core/src/main/java/com/google/android/exoplayer2/util/Util.java index 30e11f97440..7b63e5c9c53 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.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; @@ -2037,6 +2038,42 @@ private static String normalizeLanguageCodeSyntaxV21(String languageTag) { } } + /** + * 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 d7f5624ae17..e656e01e980 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 @@ -16,6 +16,7 @@ package com.google.android.exoplayer2; import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.fail; import static org.robolectric.Shadows.shadowOf; @@ -34,6 +35,7 @@ import com.google.android.exoplayer2.audio.AudioAttributes; 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; @@ -58,6 +60,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Clock; import java.io.IOException; import java.util.ArrayList; @@ -89,10 +92,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); } /** @@ -102,6 +107,7 @@ public void setUp() { @Test public void testPlayEmptyTimeline() throws Exception { Timeline timeline = Timeline.EMPTY; + Timeline expectedMaskingTimeline = new MaskingMediaSource.DummyTimeline(/* tag= */ null); FakeRenderer renderer = new FakeRenderer(); ExoPlayerTestRunner testRunner = new Builder() @@ -111,7 +117,10 @@ public void testPlayEmptyTimeline() throws Exception { .start() .blockUntilEnded(TIMEOUT_MS); testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesEqual(timeline); + testRunner.assertTimelinesSame(expectedMaskingTimeline, Timeline.EMPTY); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.formatReadCount).isEqualTo(0); assertThat(renderer.sampleBufferReadCount).isEqualTo(0); assertThat(renderer.isEnded).isFalse(); @@ -132,8 +141,10 @@ public void testPlaySinglePeriodTimeline() throws Exception { .start() .blockUntilEnded(TIMEOUT_MS); testRunner.assertNoPositionDiscontinuities(); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertTrackGroupsEqual(new TrackGroupArray(new TrackGroup(Builder.VIDEO_FORMAT))); assertThat(renderer.formatReadCount).isEqualTo(1); assertThat(renderer.sampleBufferReadCount).isEqualTo(1); @@ -155,8 +166,10 @@ public void testPlayMultiPeriodTimeline() throws Exception { testRunner.assertPositionDiscontinuityReasonsEqual( Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(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(); @@ -179,8 +192,10 @@ public void testPlayShortDurationPeriods() throws Exception { Integer[] expectedReasons = new Integer[99]; Arrays.fill(expectedReasons, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); testRunner.assertPositionDiscontinuityReasonsEqual(expectedReasons); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.formatReadCount).isEqualTo(100); assertThat(renderer.sampleBufferReadCount).isEqualTo(100); assertThat(renderer.isEnded).isTrue(); @@ -252,14 +267,17 @@ public boolean isEnded() { 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); @@ -275,8 +293,8 @@ public synchronized void prepareSourceInternal( @Nullable TransferListener mediaTransferListener) { super.prepareSourceInternal(mediaTransferListener); // We've queued a source info refresh on the playback thread's event queue. Allow the - // test thread to prepare the player with the third source, and block this thread (the - // playback thread) until the test thread's call to prepare() has returned. + // test thread to set the third source to the playlist, and block this thread (the + // playback thread) until the test thread's call to setMediaItems() has returned. queuedSourceInfoCountDownLatch.countDown(); try { completePreparationCountDownLatch.await(); @@ -291,12 +309,13 @@ public synchronized void prepareSourceInternal( // Prepare the player with a source with the first manifest and a non-empty timeline. Prepare // the player again with a source and a new manifest, which will never be exposed. Allow the - // test thread to prepare the player with a third source, and block the playback thread until - // the test thread's call to prepare() has returned. + // test thread to set a third source, and block the playback thread until the test thread's call + // to 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 { @@ -305,26 +324,32 @@ public synchronized void prepareSourceInternal( // 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(); } @@ -336,7 +361,8 @@ public void testRepeatModeChanges() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testRepeatMode") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .playUntilStartOfWindow(/* windowIndex= */ 1) .setRepeatMode(Player.REPEAT_MODE_ONE) .playUntilStartOfWindow(/* windowIndex= */ 1) @@ -371,8 +397,10 @@ public void testRepeatModeChanges() throws Exception { Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION, Player.DISCONTINUITY_REASON_PERIOD_TRANSITION); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(renderer.isEnded).isTrue(); } @@ -401,7 +429,7 @@ public void testShuffleModeEnabledChanges() throws Exception { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(renderer) .setActionSchedule(actionSchedule) .build(context) @@ -447,12 +475,13 @@ public void testAdGroupWithLoadErrorIsSkipped() throws Exception { .pause() .waitForPlaybackState(Player.STATE_READY) .executeRunnable(() -> fakeMediaSource.setNewSourceInfo(adErrorTimeline, null)) - .waitForTimelineChanged(adErrorTimeline) + .waitForTimelineChanged( + adErrorTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(fakeMediaSource) + .setMediaSources(fakeMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -547,26 +576,31 @@ public void onSeekProcessed() { } @Test - public void testSeekProcessedCalledWithIllegalSeekPosition() throws Exception { + public void testIllegalSeekPositionDoesThrow() throws Exception { + final IllegalSeekPositionException[] exception = new IllegalSeekPositionException[1]; ActionSchedule actionSchedule = - new ActionSchedule.Builder("testSeekProcessedCalledWithIllegalSeekPosition") + new ActionSchedule.Builder("testIllegalSeekPositionDoesThrow") .waitForPlaybackState(Player.STATE_BUFFERING) - // The illegal seek position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + try { + player.seekTo(/* windowIndex= */ 100, /* positionMs= */ 0); + } catch (IllegalSeekPositionException e) { + exception[0] = e; + } + } + }) .waitForPlaybackState(Player.STATE_ENDED) .build(); - final boolean[] onSeekProcessedCalled = new boolean[1]; - EventListener listener = - new EventListener() { - @Override - public void onSeekProcessed() { - onSeekProcessedCalled[0] = true; - } - }; - ExoPlayerTestRunner testRunner = - new Builder().setActionSchedule(actionSchedule).setEventListener(listener).build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); - assertThat(onSeekProcessedCalled[0]).isTrue(); + new Builder() + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertThat(exception[0]).isNotNull(); } @Test @@ -610,7 +644,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -638,7 +672,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -664,7 +698,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); @@ -682,7 +716,7 @@ public void testAllActivatedTrackSelectionAreReleasedForSinglePeriod() throws Ex FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -711,7 +745,7 @@ public void testAllActivatedTrackSelectionAreReleasedForMultiPeriods() throws Ex FakeTrackSelector trackSelector = new FakeTrackSelector(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .build(context) @@ -748,7 +782,7 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreRemad .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -787,7 +821,7 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreReuse .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderers(videoRenderer, audioRenderer) .setTrackSelector(trackSelector) .setActionSchedule(disableTrackAction) @@ -811,31 +845,35 @@ public void testAllActivatedTrackSelectionAreReleasedWhenTrackSelectionsAreReuse @Test public void testDynamicTimelineChangeReason() throws Exception { - Timeline timeline1 = new FakeTimeline(new TimelineWindowDefinition(false, false, 100000)); + Timeline timeline = new FakeTimeline(new TimelineWindowDefinition(false, false, 100000)); final Timeline timeline2 = new FakeTimeline(new TimelineWindowDefinition(false, false, 20000)); - final FakeMediaSource mediaSource = new FakeMediaSource(timeline1, Builder.VIDEO_FORMAT); + final FakeMediaSource mediaSource = new FakeMediaSource(timeline, Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("testDynamicTimelineChangeReason") .pause() - .waitForTimelineChanged(timeline1) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline2, null)) - .waitForTimelineChanged(timeline2) + .waitForTimelineChanged( + timeline2, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline1, timeline2); + testRunner.assertTimelinesSame(dummyTimeline, timeline, timeline2); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_DYNAMIC); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test - public void testRepreparationWithPositionResetAndShufflingUsesFirstPeriod() throws Exception { + public void testResetMediaItemsWithPositionResetAndShufflingUsesFirstPeriod() throws Exception { Timeline fakeTimeline = new FakeTimeline( new TimelineWindowDefinition( @@ -853,22 +891,25 @@ public void testRepreparationWithPositionResetAndShufflingUsesFirstPeriod() thro new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT), new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparationWithShuffle") + new ActionSchedule.Builder( + "testResetMediaItemsWithPositionResetAndShufflingUsesFirstPeriod") // Wait for first preparation and enable shuffling. Plays period 0. .pause() .waitForPlaybackState(Player.STATE_READY) .setShuffleModeEnabled(true) - // Reprepare with second media source (keeping state, but with position reset). + // Set the second media source (with position reset). // Plays period 1 and 0 because of the reversed fake shuffle order. - .prepareSource(secondMediaSource, /* resetPosition= */ true, /* resetState= */ false) + .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); } @@ -913,7 +954,7 @@ protected FakeMediaPeriod createFakeMediaPeriod( .executeRunnable(() -> fakeMediaPeriodHolder[0].setPreparationComplete()) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -946,8 +987,10 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isAtLeast(50L); } @@ -978,8 +1021,10 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isAtLeast(50L); } @@ -1010,9 +1055,11 @@ public void run(SimpleExoPlayer player) { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY); + testRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_RESET); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); testRunner.assertNoPositionDiscontinuities(); assertThat(positionHolder[0]).isEqualTo(0); } @@ -1058,15 +1105,29 @@ public void testStopWithResetReleasesMediaSource() throws Exception { } @Test - public void testRepreparationDoesNotResetAfterStopWithReset() throws Exception { + public void testSettingNewStartPositionPossibleAfterStopWithReset() throws Exception { Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - MediaSource secondSource = new FakeMediaSource(timeline, Builder.VIDEO_FORMAT); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); + MediaSource secondSource = new FakeMediaSource(secondTimeline, Builder.VIDEO_FORMAT); + AtomicInteger windowIndexAfterStop = new AtomicInteger(); + AtomicLong positionAfterStop = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRepreparationAfterStop") + new ActionSchedule.Builder("testSettingNewStartPositionPossibleAfterStopWithReset") .waitForPlaybackState(Player.STATE_READY) .stop(/* reset= */ true) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(secondSource) + .seek(/* windowIndex= */ 1, /* positionMs= */ 1000) + .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() @@ -1076,62 +1137,155 @@ public void testRepreparationDoesNotResetAfterStopWithReset() throws Exception { .build(context) .start() .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, timeline); + testRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_IDLE, + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED); + testRunner.assertTimelinesSame( + dummyTimeline, timeline, Timeline.EMPTY, dummyTimeline, secondTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertNoPositionDiscontinuities(); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, // stop(true) + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(windowIndexAfterStop.get()).isEqualTo(1); + assertThat(positionAfterStop.get()).isAtLeast(1000L); + testRunner.assertPlayedPeriodIndices(0, 1); } @Test - public void testSeekBeforeRepreparationPossibleAfterStopWithReset() throws Exception { - Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); - MediaSource secondSource = new FakeMediaSource(secondTimeline, Builder.VIDEO_FORMAT); + public void testResetPlaylistWithPreviousPosition() throws Exception { + Object firstWindowId = new Object(); + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedMaskingTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); + Timeline secondTimeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedMaskingTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + MediaSource secondSource = new FakeMediaSource(secondTimeline); + AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testSeekAfterStopWithReset") + new ActionSchedule.Builder("testResetPlaylistWithPreviousPosition") + .pause() .waitForPlaybackState(Player.STATE_READY) - .stop(/* reset= */ true) - .waitForPlaybackState(Player.STATE_IDLE) - // If we were still using the first timeline, this would throw. - .seek(/* windowIndex= */ 1, /* positionMs= */ 0) - .prepareSource(secondSource, /* resetPosition= */ false, /* resetState= */ true) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setMediaItems(/* windowIndex= */ 0, /* positionMs= */ 2000, secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + positionAfterReprepare.set(player.getCurrentPosition()); + } + }) + .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() .setTimeline(timeline) .setActionSchedule(actionSchedule) - .setExpectedPlayerEndedCount(2) .build(context) .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + + testRunner.assertTimelinesSame( + firstExpectedMaskingTimeline, timeline, secondExpectedMaskingTimeline, secondTimeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, - Player.TIMELINE_CHANGE_REASON_RESET, - Player.TIMELINE_CHANGE_REASON_PREPARED); - testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); - testRunner.assertPlayedPeriodIndices(0, 1); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(positionAfterReprepare.get()).isAtLeast(2000L); + } + + @Test + public void testResetPlaylistStartsFromDefaultPosition() throws Exception { + Object firstWindowId = new Object(); + Timeline timeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); + Timeline secondTimeline = + new FakeTimeline( + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); + MediaSource secondSource = new FakeMediaSource(secondTimeline); + AtomicLong positionAfterReprepare = new AtomicLong(); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testResetPlaylistStartsFromDefaultPosition") + .pause() + .waitForPlaybackState(Player.STATE_READY) + .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) + .setMediaItems(/* resetPosition= */ true, secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + positionAfterReprepare.set(player.getCurrentPosition()); + } + }) + .play() + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + testRunner.assertTimelinesSame( + firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); + assertThat(positionAfterReprepare.get()).isEqualTo(0L); } @Test - public void testReprepareAndKeepPositionWithNewMediaSource() throws Exception { + public void testResetPlaylistWithoutResettingPositionStartsFromOldPosition() throws Exception { + Object firstWindowId = new Object(); Timeline timeline = new FakeTimeline( - new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ new Object())); + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ firstWindowId)); + Timeline firstExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ firstWindowId); + Object secondWindowId = new Object(); Timeline secondTimeline = new FakeTimeline( - new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ new Object())); + new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ secondWindowId)); + Timeline secondExpectedDummyTimeline = + new MaskingMediaSource.DummyTimeline(/* tag= */ secondWindowId); MediaSource secondSource = new FakeMediaSource(secondTimeline); AtomicLong positionAfterReprepare = new AtomicLong(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testReprepareAndKeepPositionWithNewMediaSource") + new ActionSchedule.Builder("testResetPlaylistWithoutResettingPositionStartsFromOldPosition") .pause() .waitForPlaybackState(Player.STATE_READY) .playUntilPosition(/* windowIndex= */ 0, /* positionMs= */ 2000) - .prepareSource(secondSource, /* resetPosition= */ false, /* resetState= */ true) - .waitForTimelineChanged(secondTimeline) + .setMediaItems(secondSource) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .executeRunnable( new PlayerRunnable() { @Override @@ -1150,7 +1304,13 @@ public void run(SimpleExoPlayer player) { .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline, Timeline.EMPTY, secondTimeline); + testRunner.assertTimelinesSame( + firstExpectedDummyTimeline, timeline, secondExpectedDummyTimeline, secondTimeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); assertThat(positionAfterReprepare.get()).isAtLeast(2000L); } @@ -1172,8 +1332,10 @@ public void testStopDuringPreparationOverwritesPreparation() throws Exception { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(Timeline.EMPTY); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, Timeline.EMPTY); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); } @@ -1198,8 +1360,10 @@ public void testStopAndSeekAfterStopDoesNotResetTimeline() throws Exception { .start() .blockUntilActionScheduleFinished(TIMEOUT_MS) .blockUntilEnded(TIMEOUT_MS); - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); + testRunner.assertTimelinesSame(dummyTimeline, timeline); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); } @@ -1211,8 +1375,7 @@ public void testReprepareAfterPlaybackError() throws Exception { .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ true, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_READY) .build(); ExoPlayerTestRunner testRunner = @@ -1226,9 +1389,10 @@ public void testReprepareAfterPlaybackError() throws Exception { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test @@ -1250,8 +1414,7 @@ public void run(SimpleExoPlayer player) { positionHolder[0] = player.getCurrentPosition(); } }) - .prepareSource( - new FakeMediaSource(timeline), /* resetPosition= */ false, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_READY) .executeRunnable( new PlayerRunnable() { @@ -1273,52 +1436,29 @@ public void run(SimpleExoPlayer player) { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); assertThat(positionHolder[0]).isEqualTo(50); assertThat(positionHolder[1]).isEqualTo(50); } - @Test - public void testInvalidSeekPositionAfterSourceInfoRefreshStillUpdatesTimeline() throws Exception { - final Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource = new FakeMediaSource(/* timeline= */ null); - ActionSchedule actionSchedule = - new ActionSchedule.Builder("testInvalidSeekPositionSourceInfoRefreshStillUpdatesTimeline") - .waitForPlaybackState(Player.STATE_BUFFERING) - // Seeking to an invalid position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) - .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) - .waitForPlaybackState(Player.STATE_ENDED) - .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) - .setActionSchedule(actionSchedule) - .build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); - - testRunner.assertTimelinesEqual(timeline); - testRunner.assertTimelineChangeReasonsEqual(Player.TIMELINE_CHANGE_REASON_PREPARED); - } - @Test public void testInvalidSeekPositionAfterSourceInfoRefreshWithShuffleModeEnabledUsesCorrectFirstPeriod() throws Exception { - FakeMediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)); - ConcatenatingMediaSource concatenatingMediaSource = - new ConcatenatingMediaSource( - /* isAtomic= */ false, new FakeShuffleOrder(0), mediaSource, mediaSource); + FakeMediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 2)); AtomicInteger windowIndexAfterUpdate = new AtomicInteger(); ActionSchedule actionSchedule = new ActionSchedule.Builder("testInvalidSeekPositionSourceInfoRefreshUsesCorrectFirstPeriod") + .setShuffleOrder(new FakeShuffleOrder(/* length= */ 0)) .setShuffleModeEnabled(true) .waitForPlaybackState(Player.STATE_BUFFERING) // Seeking to an invalid position will end playback. - .seek(/* windowIndex= */ 100, /* positionMs= */ 0) + .seek( + /* windowIndex= */ 100, /* positionMs= */ 0, /* catchIllegalSeekException= */ true) .waitForPlaybackState(Player.STATE_ENDED) .executeRunnable( new PlayerRunnable() { @@ -1328,12 +1468,13 @@ public void run(SimpleExoPlayer player) { } }) .build(); - ExoPlayerTestRunner testRunner = - new ExoPlayerTestRunner.Builder() - .setMediaSource(concatenatingMediaSource) - .setActionSchedule(actionSchedule) - .build(context); - testRunner.start().blockUntilActionScheduleFinished(TIMEOUT_MS).blockUntilEnded(TIMEOUT_MS); + new ExoPlayerTestRunner.Builder() + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); assertThat(windowIndexAfterUpdate.get()).isEqualTo(1); } @@ -1347,7 +1488,8 @@ public void testRestartAfterEmptyTimelineWithShuffleModeEnabledUsesCorrectFirstP new ConcatenatingMediaSource(/* isAtomic= */ false, new FakeShuffleOrder(0)); AtomicInteger windowIndexAfterAddingSources = new AtomicInteger(); ActionSchedule actionSchedule = - new ActionSchedule.Builder("testRestartAfterEmptyTimelineUsesCorrectFirstPeriod") + new ActionSchedule.Builder( + "testRestartAfterEmptyTimelineWithShuffleModeEnabledUsesCorrectFirstPeriod") .setShuffleModeEnabled(true) // Preparing with an empty media source will transition to ended state. .waitForPlaybackState(Player.STATE_ENDED) @@ -1367,7 +1509,7 @@ public void run(SimpleExoPlayer player) { }) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1381,7 +1523,7 @@ public void testPlaybackErrorAndReprepareDoesNotResetPosition() throws Exception final Timeline timeline = new FakeTimeline(/* windowCount= */ 2); final long[] positionHolder = new long[3]; final int[] windowIndexHolder = new int[3]; - final FakeMediaSource secondMediaSource = new FakeMediaSource(/* timeline= */ null); + final FakeMediaSource firstMediaSource = new FakeMediaSource(timeline); ActionSchedule actionSchedule = new ActionSchedule.Builder("testPlaybackErrorDoesNotResetPosition") .pause() @@ -1398,8 +1540,7 @@ public void run(SimpleExoPlayer player) { windowIndexHolder[0] = player.getCurrentWindowIndex(); } }) - .prepareSource(secondMediaSource, /* resetPosition= */ false, /* resetState= */ false) - .waitForPlaybackState(Player.STATE_BUFFERING) + .prepare() .executeRunnable( new PlayerRunnable() { @Override @@ -1407,7 +1548,6 @@ public void run(SimpleExoPlayer player) { // Position while repreparing. positionHolder[1] = player.getCurrentPosition(); windowIndexHolder[1] = player.getCurrentWindowIndex(); - secondMediaSource.setNewSourceInfo(timeline, /* newManifest= */ null); } }) .waitForPlaybackState(Player.STATE_READY) @@ -1424,7 +1564,7 @@ public void run(SimpleExoPlayer player) { .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setTimeline(timeline) + .setMediaSources(firstMediaSource) .setActionSchedule(actionSchedule) .build(context); try { @@ -1451,7 +1591,8 @@ public void playbackErrorAndReprepareWithPositionResetKeepsWindowSequenceNumber( .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(mediaSource, /* resetPosition= */ true, /* resetState= */ false) + .seek(0, C.TIME_UNSET) + .prepare() .waitForPlaybackState(Player.STATE_READY) .play() .build(); @@ -1468,7 +1609,7 @@ public void onPlayerStateChanged( }; ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .setAnalyticsListener(listener) .build(context); @@ -1484,14 +1625,15 @@ public void onPlayerStateChanged( @Test public void testPlaybackErrorTwiceStillKeepsTimeline() throws Exception { final Timeline timeline = new FakeTimeline(/* windowCount= */ 1); - final FakeMediaSource mediaSource2 = new FakeMediaSource(/* timeline= */ null); + final FakeMediaSource mediaSource2 = new FakeMediaSource(timeline); ActionSchedule actionSchedule = new ActionSchedule.Builder("testPlaybackErrorDoesNotResetPosition") .pause() .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) - .prepareSource(mediaSource2, /* resetPosition= */ false, /* resetState= */ false) + .setMediaItems(/* resetPosition= */ false, mediaSource2) + .prepare() .waitForPlaybackState(Player.STATE_BUFFERING) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) @@ -1507,9 +1649,12 @@ public void testPlaybackErrorTwiceStillKeepsTimeline() throws Exception { } catch (ExoPlaybackException e) { // Expected exception. } - testRunner.assertTimelinesEqual(timeline, timeline); + testRunner.assertTimelinesSame(dummyTimeline, timeline, dummyTimeline, timeline); testRunner.assertTimelineChangeReasonsEqual( - Player.TIMELINE_CHANGE_REASON_PREPARED, Player.TIMELINE_CHANGE_REASON_PREPARED); + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); } @Test @@ -1539,7 +1684,8 @@ public void testSendMessagesAfterPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* positionMs= */ 50) .play() .build(); @@ -1633,17 +1779,12 @@ public void testSendMessagesAtStartAndEndOfPeriod() throws Exception { 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() @@ -1690,7 +1831,8 @@ public void testSendMessagesSeekOnDeliveryTimeAfterPreparation() throws Exceptio new ActionSchedule.Builder("testSendMessages") .waitForPlaybackState(Player.STATE_BUFFERING) .sendMessage(target, /* positionMs= */ 50) - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* positionMs= */ 50) .build(); new Builder() @@ -1731,7 +1873,8 @@ public void testSendMessagesSeekAfterDeliveryTimeAfterPreparation() throws Excep new ActionSchedule.Builder("testSendMessages") .pause() .sendMessage(target, /* positionMs= */ 50) - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* positionMs= */ 51) .play() .build(); @@ -1810,14 +1953,16 @@ public void testSendMessagesMoveCurrentWindowIndex() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* positionMs= */ 50) .executeRunnable(() -> mediaSource.setNewSourceInfo(secondTimeline, null)) - .waitForTimelineChanged(secondTimeline) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1833,7 +1978,7 @@ public void testSendMessagesMultiWindowDuringPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 2, /* positionMs= */ 50) .play() .build(); @@ -1854,7 +1999,8 @@ public void testSendMessagesMultiWindowAfterPreparation() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 2, /* positionMs= */ 50) .play() .build(); @@ -1883,15 +2029,17 @@ public void testSendMessagesMoveWindowIndex() throws Exception { ActionSchedule actionSchedule = new ActionSchedule.Builder("testSendMessages") .pause() - .waitForTimelineChanged(timeline) + .waitForTimelineChanged( + timeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .sendMessage(target, /* windowIndex = */ 1, /* positionMs= */ 50) .executeRunnable(() -> mediaSource.setNewSourceInfo(secondTimeline, null)) - .waitForTimelineChanged(secondTimeline) + .waitForTimelineChanged( + secondTimeline, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .seek(/* windowIndex= */ 0, /* positionMs= */ 0) .play() .build(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -1925,7 +2073,7 @@ public void testSendMessagesNonLinearPeriodOrder() throws Exception { .play() .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2063,16 +2211,21 @@ public void testTimelineUpdateDropsPrebufferedPeriods() throws Exception { /* windowIndex= */ 0, /* positionMs= */ C.usToMs(TimelineWindowDefinition.DEFAULT_WINDOW_DURATION_US)) .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline2, /* newManifest= */ null)) - .waitForTimelineChanged(timeline2) + .waitForTimelineChanged( + timeline2, /* expectedReason */ Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) .play() .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() .blockUntilEnded(TIMEOUT_MS); + testRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); testRunner.assertPlayedPeriodIndices(0, 1); // Assert that the second period was re-created from the new timeline. assertThat(mediaSource.getCreatedMediaPeriods()).hasSize(3); @@ -2114,7 +2267,7 @@ public void testRepeatedSeeksToUnpreparedPeriodInSameWindowKeepsWindowSequenceNu .build(); ExoPlayerTestRunner testRunner = new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2232,6 +2385,56 @@ public void run(SimpleExoPlayer player) { assertThat(eventListenerPlayWhenReady).containsExactly(true, true, true, false).inOrder(); } + @Test + public void testRecursiveTimelineChangeInStopAreReportedInCorrectOrder() throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 2); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 3); + final AtomicReference playerReference = new AtomicReference<>(); + FakeMediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + final EventListener eventListener = + new EventListener() { + @Override + public void onPlayerStateChanged(boolean playWhenReady, int state) { + if (state == Player.STATE_IDLE) { + playerReference.get().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; @@ -2284,7 +2487,7 @@ public void run(SimpleExoPlayer player) { .build(); new ExoPlayerTestRunner.Builder() .setClock(clock) - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2318,7 +2521,7 @@ public void testUpdateTrackSelectorThenSeekToUnpreparedPeriod_returnsEmptyTrackG List trackGroupsList = new ArrayList<>(); List trackSelectionsList = new ArrayList<>(); new Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setSupportedFormats(Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT) .setActionSchedule(actionSchedule) .setEventListener( @@ -2366,7 +2569,7 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { FakeRenderer renderer = new FakeRenderer(Builder.VIDEO_FORMAT); ExoPlayerTestRunner testRunner = new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setRenderers(renderer) .build(context); try { @@ -2409,49 +2612,7 @@ public void maybeThrowSourceInfoRefreshError() throws IOException { 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); @@ -2485,7 +2646,7 @@ public void removingLoopingLastPeriodFromPlaylistDoesNotThrow() throws Exception .executeRunnable(concatenatingMediaSource::clear) .build(); new Builder() - .setMediaSource(concatenatingMediaSource) + .setMediaSources(concatenatingMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2509,7 +2670,7 @@ public void seekToUnpreparedWindowWithNonZeroOffsetInConcatenationStartsAtCorrec .pause() .waitForPlaybackState(Player.STATE_BUFFERING) .seek(/* positionMs= */ 10) - .waitForTimelineChanged() + .waitForSeekProcessed() .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) .waitForTimelineChanged() .waitForPlaybackState(Player.STATE_READY) @@ -2523,7 +2684,7 @@ public void run(SimpleExoPlayer player) { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2554,7 +2715,7 @@ public void seekToUnpreparedWindowWithMultiplePeriodsInConcatenationStartsAtCorr .waitForPlaybackState(Player.STATE_BUFFERING) // Seek 10ms into the second period. .seek(/* positionMs= */ periodDurationMs + 10) - .waitForTimelineChanged() + .waitForSeekProcessed() .executeRunnable(() -> mediaSource.setNewSourceInfo(timeline, /* newManifest= */ null)) .waitForTimelineChanged() .waitForPlaybackState(Player.STATE_READY) @@ -2569,7 +2730,7 @@ public void run(SimpleExoPlayer player) { .play() .build(); new Builder() - .setMediaSource(concatenatedMediaSource) + .setMediaSources(concatenatedMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2670,10 +2831,10 @@ public void run(SimpleExoPlayer player) { player.addListener(eventListener); } }) - .seek(5_000) + .seek(/* positionMs= */ 5_000) .build(); new ExoPlayerTestRunner.Builder() - .setMediaSource(fakeMediaSource) + .setMediaSources(fakeMediaSource) .setActionSchedule(actionSchedule) .build(context) .start() @@ -2832,36 +2993,726 @@ public void onPlaybackSuppressionReasonChanged( assertThat(seenPlaybackSuppression.get()).isFalse(); } - // Internal methods. + @Test + public void testMoveMediaItem() throws Exception { + TimelineWindowDefinition firstWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + TimelineWindowDefinition secondWindowDefinition = + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ C.msToUs(10000)); + Timeline timeline1 = new FakeTimeline(firstWindowDefinition); + Timeline timeline2 = new FakeTimeline(secondWindowDefinition); + MediaSource mediaSource1 = new FakeMediaSource(timeline1); + MediaSource mediaSource2 = new FakeMediaSource(timeline2); + Timeline expectedDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMoveMediaItem") + .waitForTimelineChanged( + /* expectedTimeline= */ null, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .moveMediaItem(/* currentIndex= */ 0, /* newIndex= */ 1) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource1, mediaSource2) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); - private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { - final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); - final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1)); - return builder - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setVideoSurface(surface1); - } - }) - .executeRunnable( - new PlayerRunnable() { - @Override - public void run(SimpleExoPlayer player) { - player.setVideoSurface(surface2); - } - }); + Timeline expectedRealTimeline = new FakeTimeline(firstWindowDefinition, secondWindowDefinition); + Timeline expectedRealTimelineAfterMove = + new FakeTimeline(secondWindowDefinition, firstWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterMove); } - // Internal classes. - - private static final class PositionGrabbingMessageTarget extends PlayerTarget { - - public int windowIndex; - public long positionMs; - public int messageCount; - + @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, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedRealTimeline = + new FakeTimeline(firstWindowDefinition, secondWindowDefinition, thirdWindowDefinition); + Timeline expectedRealTimelineAfterRemove = + new FakeTimeline(secondWindowDefinition, thirdWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + } + + @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, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 3, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedRealTimeline = + new FakeTimeline(firstWindowDefinition, secondWindowDefinition, thirdWindowDefinition); + Timeline expectedRealTimelineAfterRemove = new FakeTimeline(firstWindowDefinition); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); + exoPlayerTestRunner.assertTimelinesSame( + expectedDummyTimeline, expectedRealTimeline, expectedRealTimelineAfterRemove); + } + + @Test + public void testClearMediaItems() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testClearMediaItems") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline, Timeline.EMPTY); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */); + } + + @Test + public void testMultipleModificationWithRecursiveListenerInvocations() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource mediaSource = new FakeMediaSource(timeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 2); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testMultipleModificationWithRecursiveListenerInvocations") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .clearMediaItems() + .setMediaItems(secondMediaSource) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(mediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, timeline, 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 testModifyPlaylistUnprepared_remainsInIdle_needsPrepareForBuffering() + throws Exception { + Timeline firstTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource firstMediaSource = new FakeMediaSource(firstTimeline); + Timeline secondTimeline = new FakeTimeline(/* windowCount= */ 1); + MediaSource secondMediaSource = new FakeMediaSource(secondTimeline); + int[] playbackStates = new int[4]; + int[] timelineWindowCounts = new int[4]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testModifyPlaylistUnprepared_remainsInIdle_needsPrepareForBuffering") + .waitForTimelineChanged(dummyTimeline, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 0, playbackStates, timelineWindowCounts)) + .clearMediaItems() + .executeRunnable( + new PlaybackStateCollector(/* index= */ 1, playbackStates, timelineWindowCounts)) + .setMediaItems(/* windowIndex= */ 0, /* positionMs= */ 1000, firstMediaSource) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 2, playbackStates, timelineWindowCounts)) + .addMediaItems(secondMediaSource) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 3, playbackStates, timelineWindowCounts)) + .seek(/* windowIndex= */ 1, /* positionMs= */ 2000) + .waitForSeekProcessed() + .prepare() + // The first expected buffering state arrives after prepare but not before. + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(firstMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start(/* doPrepare= */ false) + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE}, + playbackStates); + assertArrayEquals(new int[] {1, 0, 1, 2}, timelineWindowCounts); + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING /* first buffering state after prepare */, + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* initial setMediaItems */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* clear */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* set media items */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* add media items */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source update after prepare */); + Timeline expectedSecondDummyTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ false, + /* isDynamic= */ true, + /* isLive= */ false, + /* durationUs= */ C.TIME_UNSET, + AdPlaybackState.NONE)); + Timeline expectedSecondRealTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000), + new TimelineWindowDefinition( + /* periodCount= */ 1, + /* id= */ 0, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationUs= */ 10_000_000)); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, + Timeline.EMPTY, + dummyTimeline, + expectedSecondDummyTimeline, + expectedSecondRealTimeline); + } + + @Test + public void testModifyPlaylistPrepared_remainsInEnded_needsSeekForBuffering() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + FakeMediaSource secondMediaSource = new FakeMediaSource(timeline); + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testModifyPlaylistPrepared_remainsInEnded_needsSeekForBuffering") + .waitForTimelineChanged(timeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .clearMediaItems() + .waitForPlaybackState(Player.STATE_ENDED) + .addMediaItems(secondMediaSource) // add must not transition to buffering + .waitForTimelineChanged() + .clearMediaItems() // clear must remain in ended + .addMediaItems(secondMediaSource) // add again to be able to test the seek + .waitForTimelineChanged() + .seek(/* positionMs= */ 2_000) // seek must transition to buffering + .waitForSeekProcessed() + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, // first buffering + Player.STATE_READY, + Player.STATE_ENDED, // clear playlist + Player.STATE_BUFFERING, // second buffering after seek + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, + timeline, + Timeline.EMPTY, + dummyTimeline, + timeline, + Timeline.EMPTY, + dummyTimeline, + timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media items added */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* playlist cleared */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media items added */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testStopWithNoReset_modifyingPlaylistRemainsInIdleState_needsPrepareForBuffering() + throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + FakeMediaSource secondMediaSource = new FakeMediaSource(timeline); + int[] playbackStateHolder = new int[3]; + int[] windowCountHolder = new int[3]; + ActionSchedule actionSchedule = + new ActionSchedule.Builder( + "testStopWithNoReset_modifyingPlaylistRemainsInIdleState_needsPrepareForBuffering") + .waitForPlaybackState(Player.STATE_READY) + .stop(/* reset= */ false) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 0, playbackStateHolder, windowCountHolder)) + .clearMediaItems() + .executeRunnable( + new PlaybackStateCollector(/* index= */ 1, playbackStateHolder, windowCountHolder)) + .addMediaItems(secondMediaSource) + .executeRunnable( + new PlaybackStateCollector(/* index= */ 2, playbackStateHolder, windowCountHolder)) + .prepare() + .waitForPlaybackState(Player.STATE_BUFFERING) + .waitForPlaybackState(Player.STATE_READY) + .waitForPlaybackState(Player.STATE_ENDED) + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + assertArrayEquals( + new int[] {Player.STATE_IDLE, Player.STATE_IDLE, Player.STATE_IDLE}, playbackStateHolder); + assertArrayEquals(new int[] {1, 0, 1}, windowCountHolder); + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, + Player.STATE_BUFFERING, // first buffering + Player.STATE_READY, + Player.STATE_IDLE, // stop + Player.STATE_BUFFERING, + Player.STATE_READY, + Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame( + dummyTimeline, timeline, Timeline.EMPTY, dummyTimeline, timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE, /* source prepared */ + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* clear media items */, + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item add (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testPrepareWhenAlreadyPreparedIsANoop() throws Exception { + Timeline timeline = new FakeTimeline(/* windowCount= */ 1); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testPrepareWhenAlreadyPreparedIsANoop") + .waitForPlaybackState(Player.STATE_READY) + .prepare() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setTimeline(timeline) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + + exoPlayerTestRunner.assertPlaybackStatesEqual( + Player.STATE_IDLE, Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED); + exoPlayerTestRunner.assertTimelinesSame(dummyTimeline, timeline); + exoPlayerTestRunner.assertTimelineChangeReasonsEqual( + Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED /* media item set (masked timeline) */, + Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE /* source prepared */); + } + + @Test + public void testSeekToIndexLargerThanNumberOfPlaylistItems() throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource( + /* isAtomic= */ false, + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT), + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + int[] currentWindowIndices = new int[1]; + long[] currentPlaybackPositions = new long[1]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexLargerThanNumberOfPlaylistItems") + .waitForSeekProcessed() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + } + }) + .build(); + ExoPlayerTestRunner testRunner = + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {5_000}, currentPlaybackPositions); + assertArrayEquals(new int[] {seekToWindowIndex}, currentWindowIndices); + } + + @Test + public void testSeekToIndexWithEmptyMultiWindowMediaSource() throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + int[] currentWindowIndices = new int[2]; + long[] currentPlaybackPositions = new long[2]; + long[] windowCounts = new long[2]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexWithEmptyMultiWindowMediaSource") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + windowCounts[0] = player.getCurrentTimeline().getWindowCount(); + } + }) + .executeRunnable( + () -> { + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPlaybackPositions[1] = player.getCurrentPosition(); + windowCounts[1] = player.getCurrentTimeline().getWindowCount(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {0, 2}, windowCounts); + assertArrayEquals(new int[] {seekToWindowIndex, seekToWindowIndex}, currentWindowIndices); + assertArrayEquals(new long[] {5_000, 5_000}, currentPlaybackPositions); + } + + @Test + public void testEmptyMultiWindowMediaSource_doesNotEnterBufferState() throws Exception { + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testEmptyMultiWindowMediaSource_doesNotEnterBufferState") + .waitForTimelineChanged() + .build(); + ExoPlayerTestRunner exoPlayerTestRunner = + new Builder() + .setMediaSources(concatenatingMediaSource) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + exoPlayerTestRunner.assertPlaybackStatesEqual(1, 4); + } + + @Test + public void testSeekToIndexWithEmptyMultiWindowMediaSource_usesLazyPreparation() + throws Exception { + Timeline fakeTimeline = + new FakeTimeline( + new TimelineWindowDefinition( + /* isSeekable= */ true, /* isDynamic= */ false, /* durationUs= */ 10_000)); + ConcatenatingMediaSource concatenatingMediaSource = + new ConcatenatingMediaSource(/* isAtomic= */ false); + int[] currentWindowIndices = new int[2]; + long[] currentPlaybackPositions = new long[2]; + long[] windowCounts = new long[2]; + int seekToWindowIndex = 1; + ActionSchedule actionSchedule = + new ActionSchedule.Builder("testSeekToIndexWithEmptyMultiWindowMediaSource") + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[0] = player.getCurrentWindowIndex(); + currentPlaybackPositions[0] = player.getCurrentPosition(); + windowCounts[0] = player.getCurrentTimeline().getWindowCount(); + } + }) + .executeRunnable( + () -> { + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + concatenatingMediaSource.addMediaSource( + new FakeMediaSource(fakeTimeline, Builder.VIDEO_FORMAT)); + }) + .waitForTimelineChanged() + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + currentWindowIndices[1] = player.getCurrentWindowIndex(); + currentPlaybackPositions[1] = player.getCurrentPosition(); + windowCounts[1] = player.getCurrentTimeline().getWindowCount(); + } + }) + .build(); + new ExoPlayerTestRunner.Builder() + .setMediaSources(concatenatingMediaSource) + .setUseLazyPreparation(/* useLazyPreparation= */ true) + .initialSeek(seekToWindowIndex, 5000) + .setActionSchedule(actionSchedule) + .build(context) + .start() + .blockUntilActionScheduleFinished(TIMEOUT_MS) + .blockUntilEnded(TIMEOUT_MS); + assertArrayEquals(new long[] {0, 2}, windowCounts); + assertArrayEquals(new int[] {seekToWindowIndex, seekToWindowIndex}, currentWindowIndices); + assertArrayEquals(new long[] {5_000, 5_000}, currentPlaybackPositions); + } + + // Internal methods. + + private static ActionSchedule.Builder addSurfaceSwitch(ActionSchedule.Builder builder) { + final Surface surface1 = new Surface(new SurfaceTexture(/* texName= */ 0)); + final Surface surface2 = new Surface(new SurfaceTexture(/* texName= */ 1)); + return builder + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setVideoSurface(surface1); + } + }) + .executeRunnable( + new PlayerRunnable() { + @Override + public void run(SimpleExoPlayer player) { + player.setVideoSurface(surface2); + } + }); + } + + // Internal classes. + + private static final class PositionGrabbingMessageTarget extends PlayerTarget { + + public int windowIndex; + public long positionMs; + public int messageCount; + public PositionGrabbingMessageTarget() { windowIndex = C.INDEX_UNSET; positionMs = C.POSITION_UNSET; @@ -2890,4 +3741,38 @@ public void run(SimpleExoPlayer player) { timeline = player.getCurrentTimeline(); } } + /** + * Provides a wrapper for a {@link Runnable} which does collect playback states and window counts. + * Can be used with {@link ActionSchedule.Builder#executeRunnable(Runnable)} to verify that a + * playback state did not change and hence no observable callback is called. + * + *

This is specifically useful in cases when the test may end before a given state arrives or + * when an action of the action schedule might execute before a callback is called. + */ + public static class PlaybackStateCollector extends PlayerRunnable { + + private final int[] playbackStates; + private final int[] timelineWindowCount; + private final int index; + + /** + * Creates the collector. + * + * @param index The index to populate. + * @param playbackStates An array of playback states to populate. + * @param timelineWindowCount An array of window counts to populate. + */ + public PlaybackStateCollector(int index, int[] playbackStates, int[] timelineWindowCount) { + Assertions.checkArgument(playbackStates.length > index && timelineWindowCount.length > index); + this.playbackStates = playbackStates; + this.timelineWindowCount = timelineWindowCount; + this.index = index; + } + + @Override + public void run(SimpleExoPlayer player) { + playbackStates[index] = player.getPlaybackState(); + timelineWindowCount[index] = player.getCurrentTimeline().getWindowCount(); + } + } } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java index 1a0e13b6c1f..276ed2db976 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaPeriodQueueTest.java @@ -21,15 +21,17 @@ import android.net.Uri; import androidx.test.ext.junit.runners.AndroidJUnit4; -import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.source.ads.AdPlaybackState; import com.google.android.exoplayer2.source.ads.SinglePeriodAdTimeline; +import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelector; import com.google.android.exoplayer2.trackselection.TrackSelectorResult; import com.google.android.exoplayer2.upstream.Allocator; +import java.util.Collections; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -50,19 +52,20 @@ public final class MediaPeriodQueueTest { private MediaPeriodQueue mediaPeriodQueue; private AdPlaybackState adPlaybackState; - private Timeline timeline; private Object periodUid; private PlaybackInfo playbackInfo; private RendererCapabilities[] rendererCapabilities; private TrackSelector trackSelector; private Allocator allocator; - private MediaSource mediaSource; + private Playlist playlist; + private FakeMediaSource fakeMediaSource; + private Playlist.MediaSourceHolder mediaSourceHolder; @Before public void setUp() { mediaPeriodQueue = new MediaPeriodQueue(); - mediaSource = mock(MediaSource.class); + playlist = mock(Playlist.class); rendererCapabilities = new RendererCapabilities[0]; trackSelector = mock(TrackSelector.class); allocator = mock(Allocator.class); @@ -70,7 +73,7 @@ public void setUp() { @Test public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { - setupTimeline(/* initialPositionUs= */ 0); + setupTimeline(); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_UNSET, @@ -81,7 +84,7 @@ public void getNextMediaPeriodInfo_withoutAds_returnsLastMediaPeriodInfo() { @Test public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos() { - setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ 0); + setupTimeline(/* adGroupTimesUs= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 0); assertNextMediaPeriodInfoIsAd(/* adGroupIndex= */ 0, /* contentPositionUs= */ 0); advance(); @@ -95,10 +98,7 @@ public void getNextMediaPeriodInfo_withPrerollAd_returnsCorrectMediaPeriodInfos( @Test public void getNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, @@ -133,10 +133,7 @@ public void getNextMediaPeriodInfo_withMidrollAds_returnsCorrectMediaPeriodInfos @Test public void getNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPeriodInfos() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - C.TIME_END_OF_SOURCE); + setupTimeline(/* adGroupTimesUs= */ FIRST_AD_START_TIME_US, C.TIME_END_OF_SOURCE); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ FIRST_AD_START_TIME_US, @@ -169,7 +166,7 @@ public void getNextMediaPeriodInfo_withMidrollAndPostroll_returnsCorrectMediaPer @Test public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaPeriodInfo() { - setupTimeline(/* initialPositionUs= */ 0, /* adGroupTimesUs= */ C.TIME_END_OF_SOURCE); + setupTimeline(/* adGroupTimesUs= */ C.TIME_END_OF_SOURCE); assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( /* startPositionUs= */ 0, /* endPositionUs= */ C.TIME_END_OF_SOURCE, @@ -189,10 +186,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeAfterReadingPeriod_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -202,10 +196,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP enqueueNext(); // Second ad. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US + 1); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US + 1); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -219,10 +211,7 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP @Test public void updateQueuedPeriods_withDurationChangeBeforeReadingPeriod_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US); + setupTimeline(/* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); enqueueNext(); // Content before first ad. @@ -233,10 +222,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading first ad. // Change position of first ad (= change duration of content before first ad). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US + 1, - SECOND_AD_START_TIME_US); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US + 1, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -251,7 +238,6 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP public void updateQueuedPeriods_withDurationChangeInReadingPeriodAfterReadingPosition_handlesChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -265,10 +251,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); long readingPositionAtStartOfContentBetweenAds = FIRST_AD_START_TIME_US + AD_DURATION_US; @@ -285,7 +269,6 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP public void updateQueuedPeriods_withDurationChangeInReadingPeriodBeforeReadingPosition_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -299,10 +282,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); long readingPositionAtEndOfContentBetweenAds = SECOND_AD_START_TIME_US + AD_DURATION_US; @@ -319,7 +300,6 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP public void updateQueuedPeriods_withDurationChangeInReadingPeriodReadToEnd_doesntHandleChangeAndRemovesPeriodsAfterChangedPeriod() { setupTimeline( - /* initialPositionUs= */ 0, /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US); setAdGroupLoaded(/* adGroupIndex= */ 0); @@ -333,10 +313,8 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP advanceReading(); // Reading content between ads. // Change position of second ad (= change duration of content between ads). - setupTimeline( - /* initialPositionUs= */ 0, - /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, - SECOND_AD_START_TIME_US - 1000); + updateAdPlaybackStateAndTimeline( + /* adGroupTimesUs= */ FIRST_AD_START_TIME_US, SECOND_AD_START_TIME_US - 1000); setAdGroupLoaded(/* adGroupIndex= */ 0); setAdGroupLoaded(/* adGroupIndex= */ 1); boolean changeHandled = @@ -347,16 +325,25 @@ public void getNextMediaPeriodInfo_withPostrollLoadError_returnsEmptyFinalMediaP assertThat(getQueueLength()).isEqualTo(3); } - private void setupTimeline(long initialPositionUs, long... adGroupTimesUs) { + private void setupTimeline(long... adGroupTimesUs) { adPlaybackState = new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); - timeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + + // Create a media source holder. + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + fakeMediaSource = new FakeMediaSource(adTimeline); + mediaSourceHolder = new Playlist.MediaSourceHolder(fakeMediaSource, false); + mediaSourceHolder.mediaSource.prepareSourceInternal(/* mediaTransferListener */ null); + + Timeline timeline = createPlaylistTimeline(); periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); mediaPeriodQueue.setTimeline(timeline); + playbackInfo = new PlaybackInfo( timeline, - mediaPeriodQueue.resolveMediaPeriodIdForAds(periodUid, initialPositionUs), + mediaPeriodQueue.resolveMediaPeriodIdForAds(periodUid, /* positionUs= */ 0), /* startPositionUs= */ 0, /* contentPositionUs= */ 0, Player.STATE_READY, @@ -370,6 +357,25 @@ private void setupTimeline(long initialPositionUs, long... adGroupTimesUs) { /* positionUs= */ 0); } + private void updateAdPlaybackStateAndTimeline(long... adGroupTimesUs) { + adPlaybackState = + new AdPlaybackState(adGroupTimesUs).withContentDurationUs(CONTENT_DURATION_US); + updateTimeline(); + } + + private void updateTimeline() { + SinglePeriodAdTimeline adTimeline = + new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); + fakeMediaSource.setNewSourceInfo(adTimeline, /* manifest */ null); + mediaPeriodQueue.setTimeline(createPlaylistTimeline()); + } + + private Playlist.PlaylistTimeline createPlaylistTimeline() { + return new Playlist.PlaylistTimeline( + Collections.singleton(mediaSourceHolder), + new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1)); + } + private void advance() { enqueueNext(); if (mediaPeriodQueue.getLoadingPeriod() != mediaPeriodQueue.getPlayingPeriod()) { @@ -390,7 +396,7 @@ private void enqueueNext() { rendererCapabilities, trackSelector, allocator, - mediaSource, + playlist, getNextMediaPeriodInfo(), new TrackSelectorResult( new RendererConfiguration[0], new TrackSelection[0], /* info= */ null)); @@ -422,11 +428,6 @@ private void setAdGroupFailedToLoad(int adGroupIndex) { updateTimeline(); } - private void updateTimeline() { - timeline = new SinglePeriodAdTimeline(CONTENT_TIMELINE, adPlaybackState); - mediaPeriodQueue.setTimeline(timeline); - } - private void assertGetNextMediaPeriodInfoReturnsContentMediaPeriod( long startPositionUs, long endPositionUs, diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java new file mode 100644 index 00000000000..cc551db8ac0 --- /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 d6e65cb34d5..5110ad411c7 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,148 @@ public void testMultiPeriodTimeline() { 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.isLive = 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.isLive = 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.isLive, + 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 fb3e0936ae0..7117f426f39 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/analytics/AnalyticsCollectorTest.java @@ -44,7 +44,6 @@ import com.google.android.exoplayer2.testutil.ActionSchedule; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner; -import com.google.android.exoplayer2.testutil.ExoPlayerTestRunner.Builder; import com.google.android.exoplayer2.testutil.FakeMediaSource; import com.google.android.exoplayer2.testutil.FakeRenderer; import com.google.android.exoplayer2.testutil.FakeTimeline; @@ -133,24 +132,29 @@ public void testEmptyTimeline() throws Exception { assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, WINDOW_0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* DYNAMIC */); listener.assertNoMoreEvents(); } @Test public void testSinglePeriod() throws Exception { FakeMediaSource mediaSource = - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT); + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady */, WINDOW_0 /* BUFFERING */, period0 /* READY */, period0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, WINDOW_0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0 /* started */, period0 /* stopped */); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)).containsExactly(period0); @@ -179,9 +183,14 @@ public void testSinglePeriod() throws Exception { public void testAutomaticPeriodTransition() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT), new FakeMediaSource( - SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT)); + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT), + new FakeMediaSource( + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); populateEventIds(listener.lastReportedTimeline); @@ -191,7 +200,8 @@ public void testAutomaticPeriodTransition() throws Exception { WINDOW_0 /* BUFFERING */, period0 /* READY */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0, period0, period0, period0); @@ -233,8 +243,8 @@ public void testAutomaticPeriodTransition() throws Exception { public void testPeriodTransitionWithRendererChange() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.AUDIO_FORMAT)); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); TestAnalyticsListener listener = runAnalyticsTest(mediaSource); populateEventIds(listener.lastReportedTimeline); @@ -246,7 +256,8 @@ public void testPeriodTransitionWithRendererChange() throws Exception { period1 /* BUFFERING */, period1 /* READY */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0, period0, period0, period0); @@ -286,8 +297,8 @@ public void testPeriodTransitionWithRendererChange() throws Exception { public void testSeekToOtherPeriod() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.AUDIO_FORMAT)); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .pause() @@ -308,7 +319,8 @@ public void testSeekToOtherPeriod() throws Exception { period1 /* READY */, period1 /* setPlayWhenReady=true */, period1 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period1); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period1); @@ -350,9 +362,11 @@ public void testSeekToOtherPeriod() throws Exception { public void testSeekBackAfterReadingAhead() throws Exception { MediaSource mediaSource = new ConcatenatingMediaSource( - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT), + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT), new FakeMediaSource( - SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT, Builder.AUDIO_FORMAT)); + SINGLE_PERIOD_TIMELINE, + ExoPlayerTestRunner.Builder.VIDEO_FORMAT, + ExoPlayerTestRunner.Builder.AUDIO_FORMAT)); long periodDurationMs = SINGLE_PERIOD_TIMELINE.getWindow(/* windowIndex= */ 0, new Window()).getDurationMs(); ActionSchedule actionSchedule = @@ -380,7 +394,8 @@ public void testSeekBackAfterReadingAhead() throws Exception { period1Seq2 /* BUFFERING */, period1Seq2 /* READY */, period1Seq2 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly(WINDOW_0 /* PLAYLIST_CHANGED */, period0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)) .containsExactly(period0, period1Seq2); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); @@ -428,18 +443,28 @@ public void testSeekBackAfterReadingAhead() throws Exception { @Test public void testPrepareNewSource() throws Exception { - MediaSource mediaSource1 = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); - MediaSource mediaSource2 = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + MediaSource mediaSource1 = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); + MediaSource mediaSource2 = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .pause() .waitForPlaybackState(Player.STATE_READY) - .prepareSource(mediaSource2) + .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 */, @@ -451,12 +476,16 @@ public void testPrepareNewSource() throws Exception { period0Seq1 /* READY */, period0Seq1 /* ENDED */); assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) - .containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* reset */, WINDOW_0 /* prepared */); + .containsExactly( + WINDOW_0 /* PLAYLIST_CHANGE */, + WINDOW_0 /* DYNAMIC */, + WINDOW_0 /* PLAYLIST_CHANGE */, + WINDOW_0 /* DYNAMIC */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly(period0Seq0, period0Seq0, period0Seq1, period0Seq1); assertThat(listener.getEvents(EVENT_TRACKS_CHANGED)) .containsExactly( - period0Seq0 /* prepared */, WINDOW_0 /* reset */, period0Seq1 /* prepared */); + period0Seq0 /* prepared */, WINDOW_0 /* setMediaItems */, period0Seq1 /* prepared */); assertThat(listener.getEvents(EVENT_LOAD_STARTED)) .containsExactly( WINDOW_0 /* manifest */, @@ -490,19 +519,20 @@ public void testPrepareNewSource() throws Exception { @Test public void testReprepareAfterError() throws Exception { - MediaSource mediaSource = new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + MediaSource mediaSource = + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); ActionSchedule actionSchedule = new ActionSchedule.Builder("AnalyticsCollectorTest") .waitForPlaybackState(Player.STATE_READY) .throwPlaybackException(ExoPlaybackException.createForSource(new IOException())) .waitForPlaybackState(Player.STATE_IDLE) .seek(/* positionMs= */ 0) - .prepareSource(mediaSource, /* resetPosition= */ false, /* resetState= */ false) + .prepare() .waitForPlaybackState(Player.STATE_ENDED) .build(); TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_PLAYER_STATE_CHANGED)) .containsExactly( WINDOW_0 /* setPlayWhenReady=true */, @@ -556,7 +586,7 @@ public void testReprepareAfterError() throws Exception { @Test public void testDynamicTimelineChange() throws Exception { MediaSource childMediaSource = - new FakeMediaSource(SINGLE_PERIOD_TIMELINE, Builder.VIDEO_FORMAT); + new FakeMediaSource(SINGLE_PERIOD_TIMELINE, ExoPlayerTestRunner.Builder.VIDEO_FORMAT); final ConcatenatingMediaSource concatenatedMediaSource = new ConcatenatingMediaSource(childMediaSource, childMediaSource); long periodDurationMs = @@ -588,7 +618,11 @@ public void testDynamicTimelineChange() throws Exception { period1Seq0 /* setPlayWhenReady=true */, period1Seq0 /* BUFFERING */, period1Seq0 /* ENDED */); - assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)).containsExactly(WINDOW_0, period1Seq0); + assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED)) + .containsExactly( + WINDOW_0 /* PLAYLIST_CHANGED */, + window0Period1Seq0 /* DYNAMIC (concatenated timeline replaces dummy) */, + period1Seq0 /* DYNAMIC (child sources in concatenating source moved) */); assertThat(listener.getEvents(EVENT_LOADING_CHANGED)) .containsExactly( window0Period1Seq0, window0Period1Seq0, window0Period1Seq0, window0Period1Seq0); @@ -642,7 +676,7 @@ public void run(SimpleExoPlayer player) { .build(); TestAnalyticsListener listener = runAnalyticsTest(mediaSource, actionSchedule); - populateEventIds(SINGLE_PERIOD_TIMELINE); + populateEventIds(listener.lastReportedTimeline); assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0); assertThat(listener.getEvents(EVENT_SEEK_PROCESSED)).containsExactly(period0); } @@ -709,7 +743,7 @@ private static TestAnalyticsListener runAnalyticsTest( TestAnalyticsListener listener = new TestAnalyticsListener(); try { new ExoPlayerTestRunner.Builder() - .setMediaSource(mediaSource) + .setMediaSources(mediaSource) .setRenderersFactory(renderersFactory) .setAnalyticsListener(listener) .setActionSchedule(actionSchedule) @@ -731,7 +765,7 @@ private static final class FakeVideoRenderer extends FakeRenderer { private boolean renderedFirstFrame; public FakeVideoRenderer(Handler handler, VideoRendererEventListener eventListener) { - super(Builder.VIDEO_FORMAT); + super(ExoPlayerTestRunner.Builder.VIDEO_FORMAT); eventDispatcher = new VideoRendererEventListener.EventDispatcher(handler, eventListener); decoderCounters = new DecoderCounters(); } @@ -789,7 +823,7 @@ private static final class FakeAudioRenderer extends FakeRenderer { private boolean notifiedAudioSessionId; public FakeAudioRenderer(Handler handler, AudioRendererEventListener eventListener) { - super(Builder.AUDIO_FORMAT); + super(ExoPlayerTestRunner.Builder.AUDIO_FORMAT); eventDispatcher = new AudioRendererEventListener.EventDispatcher(handler, eventListener); decoderCounters = new DecoderCounters(); } @@ -873,10 +907,12 @@ private static final class TestAnalyticsListener implements AnalyticsListener { public Timeline lastReportedTimeline; + private final List reportedTimelines; private final ArrayList reportedEvents; public TestAnalyticsListener() { reportedEvents = new ArrayList<>(); + reportedTimelines = new ArrayList<>(); lastReportedTimeline = Timeline.EMPTY; } @@ -906,6 +942,7 @@ public void onPlayerStateChanged( @Override public void onTimelineChanged(EventTime eventTime, int reason) { lastReportedTimeline = eventTime.timeline; + reportedTimelines.add(eventTime.timeline); reportedEvents.add(new ReportedEvent(EVENT_TIMELINE_CHANGED, eventTime)); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/Action.java index 7936f9b51cf..7ea6bc67b5c 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 @@ -23,6 +23,7 @@ import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.IllegalSeekPositionException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.PlayerMessage; @@ -31,6 +32,7 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.ActionSchedule.ActionNode; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerRunnable; import com.google.android.exoplayer2.testutil.ActionSchedule.PlayerTarget; @@ -39,6 +41,8 @@ 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. */ public abstract class Action { @@ -115,6 +119,7 @@ public static final class Seek extends Action { private final Integer windowIndex; private final long positionMs; + private final boolean catchIllegalSeekException; /** * Action calls {@link Player#seekTo(long)}. @@ -126,6 +131,7 @@ public Seek(String tag, long positionMs) { super(tag, "Seek:" + positionMs); this.windowIndex = null; this.positionMs = positionMs; + catchIllegalSeekException = false; } /** @@ -134,24 +140,191 @@ public Seek(String tag, long positionMs) { * @param tag A tag to use for logging. * @param windowIndex The window to seek to. * @param positionMs The seek position. + * @param catchIllegalSeekException Whether {@link IllegalSeekPositionException} should be + * silently caught or not. */ - public Seek(String tag, int windowIndex, long positionMs) { + public Seek(String tag, int windowIndex, long positionMs, boolean catchIllegalSeekException) { super(tag, "Seek:" + positionMs); this.windowIndex = windowIndex; this.positionMs = positionMs; + this.catchIllegalSeekException = catchIllegalSeekException; } @Override protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { - if (windowIndex == null) { - player.seekTo(positionMs); - } else { - player.seekTo(windowIndex, positionMs); + try { + if (windowIndex == null) { + player.seekTo(positionMs); + } else { + player.seekTo(windowIndex, positionMs); + } + } catch (IllegalSeekPositionException e) { + if (!catchIllegalSeekException) { + throw e; + } } } } + /** Calls {@link SimpleExoPlayer#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#addMediaItems(List)}. */ + public static final class AddMediaItems extends Action { + + private final MediaSource[] mediaSources; + + /** + * @param tag A tag to use for logging. + * @param mediaSources The media sources to be added to the playlist. + */ + public AddMediaItems(String tag, MediaSource... mediaSources) { + super(tag, /* description= */ "AddMediaItems"); + this.mediaSources = mediaSources; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.addMediaItems(Arrays.asList(mediaSources)); + } + } + + /** 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 { @@ -210,7 +383,6 @@ protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlayWhenReady(playWhenReady); } - } /** Broadcasts an {@link Intent}. */ @@ -316,52 +488,31 @@ protected void doActionImpl( } } - /** Calls {@link ExoPlayer#prepare(MediaSource)}. */ - public static final class PrepareSource extends Action { - - private final MediaSource mediaSource; - private final boolean resetPosition; - private final boolean resetState; - - /** - * @param tag A tag to use for logging. - * @param mediaSource The {@link MediaSource} to prepare the player with. - */ - public PrepareSource(String tag, MediaSource mediaSource) { - this(tag, mediaSource, true, true); - } - - /** - * @param tag A tag to use for logging. - * @param mediaSource The {@link MediaSource} to prepare the player with. - * @param resetPosition Whether the player's position should be reset. - */ - public PrepareSource( - String tag, MediaSource mediaSource, boolean resetPosition, boolean resetState) { - super(tag, "PrepareSource"); - this.mediaSource = mediaSource; - this.resetPosition = resetPosition; - this.resetState = resetState; + /** Calls {@link ExoPlayer#prepare()}. */ + public static final class Prepare extends Action { + /** @param tag A tag to use for logging. */ + public Prepare(String tag) { + super(tag, "Prepare"); } @Override protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { - player.prepare(mediaSource, resetPosition, resetState); + player.prepare(); } } /** Calls {@link Player#setRepeatMode(int)}. */ public static final class SetRepeatMode extends Action { - private final @Player.RepeatMode int repeatMode; + @Player.RepeatMode private final int repeatMode; /** * @param tag A tag to use for logging. * @param repeatMode The repeat mode. */ public SetRepeatMode(String tag, @Player.RepeatMode int repeatMode) { - super(tag, "SetRepeatMode:" + repeatMode); + super(tag, "SetRepeatMode: " + repeatMode); this.repeatMode = repeatMode; } @@ -372,6 +523,27 @@ protected void doActionImpl( } } + /** Calls {@link ExoPlayer#setShuffleOrder(ShuffleOrder)} . */ + public static final class SetShuffleOrder extends Action { + + private final ShuffleOrder shuffleOrder; + + /** + * @param tag A tag to use for logging. + * @param shuffleOrder The shuffle order. + */ + public SetShuffleOrder(String tag, ShuffleOrder shuffleOrder) { + super(tag, "SetShufflerOrder"); + this.shuffleOrder = shuffleOrder; + } + + @Override + protected void doActionImpl( + SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { + player.setShuffleOrder(shuffleOrder); + } + } + /** Calls {@link Player#setShuffleModeEnabled(boolean)}. */ public static final class SetShuffleModeEnabled extends Action { @@ -382,7 +554,7 @@ public static final class SetShuffleModeEnabled extends Action { * @param shuffleModeEnabled Whether shuffling is enabled. */ public SetShuffleModeEnabled(String tag, boolean shuffleModeEnabled) { - super(tag, "SetShuffleModeEnabled:" + shuffleModeEnabled); + super(tag, "SetShuffleModeEnabled: " + shuffleModeEnabled); this.shuffleModeEnabled = shuffleModeEnabled; } @@ -469,7 +641,6 @@ protected void doActionImpl( SimpleExoPlayer player, DefaultTrackSelector trackSelector, Surface surface) { player.setPlaybackParameters(playbackParameters); } - } /** Throws a playback exception on the playback thread. */ @@ -566,18 +737,35 @@ protected void doActionImpl( /** 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 @@ -595,7 +783,9 @@ protected void doActionAndScheduleNextImpl( @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); } @@ -783,7 +973,7 @@ protected void doActionImpl( } } - /** Calls {@link Runnable#run()}. */ + /** Calls {@code Runnable.run()}. */ public static final class ExecuteRunnable extends Action { private final Runnable runnable; diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ActionSchedule.java index cf363f6266a..e1e5f769036 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 @@ -29,10 +29,10 @@ import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.audio.AudioAttributes; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.testutil.Action.ClearVideoSurface; import com.google.android.exoplayer2.testutil.Action.ExecuteRunnable; import com.google.android.exoplayer2.testutil.Action.PlayUntilPosition; -import com.google.android.exoplayer2.testutil.Action.PrepareSource; import com.google.android.exoplayer2.testutil.Action.Seek; import com.google.android.exoplayer2.testutil.Action.SendBroadcast; import com.google.android.exoplayer2.testutil.Action.SendMessages; @@ -42,6 +42,7 @@ 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; @@ -173,7 +174,19 @@ public Builder seek(long positionMs) { * @return The builder, for convenience. */ public Builder seek(int windowIndex, long positionMs) { - return apply(new Seek(tag, windowIndex, positionMs)); + return apply(new Seek(tag, windowIndex, positionMs, /* catchIllegalSeekException= */ false)); + } + + /** + * Schedules a seek action to be executed. + * + * @param windowIndex The window to seek to. + * @param positionMs The seek position. + * @param catchIllegalSeekException Whether an illegal seek position should be caught or not. + * @return The builder, for convenience. + */ + public Builder seek(int windowIndex, long positionMs, boolean catchIllegalSeekException) { + return apply(new Seek(tag, windowIndex, positionMs, catchIllegalSeekException)); } /** @@ -314,23 +327,99 @@ public Builder setAudioAttributes(AudioAttributes audioAttributes, boolean handl } /** - * Schedules a new source preparation action. + * Schedules a set media items action to be executed. + * + * @param windowIndex The window index to start playback from or {@link C#INDEX_UNSET} if the + * playback position should not be reset. + * @param positionMs The position in milliseconds from where playback should start. If {@link + * C#TIME_UNSET} is passed the default position is used. In any case, if {@code windowIndex} + * is set to {@link C#INDEX_UNSET} the position is not reset at all and this parameter is + * ignored. + * @return The builder, for convenience. + */ + public Builder setMediaItems(int windowIndex, long positionMs, MediaSource... sources) { + return apply(new Action.SetMediaItems(tag, windowIndex, positionMs, sources)); + } + + /** + * Schedules a set media items action to be executed. * + * @param resetPosition Whether the playback position should be reset. * @return The builder, for convenience. */ - public Builder prepareSource(MediaSource mediaSource) { - return apply(new PrepareSource(tag, mediaSource)); + public Builder setMediaItems(boolean resetPosition, MediaSource... sources) { + return apply(new Action.SetMediaItemsResetPosition(tag, resetPosition, sources)); } /** - * Schedules a new source preparation action. + * Schedules a set media items action to be executed. * - * @see com.google.android.exoplayer2.ExoPlayer#prepare(MediaSource, boolean, boolean) + * @param mediaSources The media sources to add. * @return The builder, for convenience. */ - public Builder prepareSource( - MediaSource mediaSource, boolean resetPosition, boolean resetState) { - return apply(new PrepareSource(tag, mediaSource, resetPosition, resetState)); + public Builder setMediaItems(MediaSource... mediaSources) { + return apply( + new Action.SetMediaItems( + tag, /* windowIndex= */ C.INDEX_UNSET, /* positionMs= */ C.TIME_UNSET, mediaSources)); + } + /** + * Schedules a add media items action to be executed. + * + * @param mediaSources The media sources to add. + * @return The builder, for convenience. + */ + public Builder addMediaItems(MediaSource... mediaSources) { + return apply(new Action.AddMediaItems(tag, mediaSources)); + } + + /** + * Schedules a move media item action to be executed. + * + * @param currentIndex The current index of the item to move. + * @param newIndex The index after the item has been moved. + * @return The builder, for convenience. + */ + public Builder moveMediaItem(int currentIndex, int newIndex) { + return apply(new Action.MoveMediaItem(tag, currentIndex, newIndex)); + } + + /** + * Schedules a remove media item action to be executed. + * + * @param index The index of the media item to be removed. + * @return The builder, for convenience. + */ + public Builder removeMediaItem(int index) { + return apply(new Action.RemoveMediaItem(tag, index)); + } + + /** + * Schedules a remove media items action to be executed. + * + * @param fromIndex The start of the range of media items to be removed. + * @param toIndex The end of the range of media items to be removed (exclusive). + * @return The builder, for convenience. + */ + public Builder 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)); } /** @@ -343,7 +432,17 @@ public Builder setRepeatMode(@Player.RepeatMode int repeatMode) { } /** - * Schedules a shuffle setting action. + * Schedules a set shuffle order action to be executed. + * + * @param shuffleOrder The shuffle order. + * @return The builder, for convenience. + */ + public Builder setShuffleOrder(ShuffleOrder shuffleOrder) { + return apply(new SetShuffleOrder(tag, shuffleOrder)); + } + + /** + * Schedules a shuffle setting action to be executed. * * @return The builder, for convenience. */ @@ -405,18 +504,19 @@ public Builder sendBroadcast(Intent intent) { * @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 5f01d7724b2..b00ad287bb7 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 final void onStart(HostActivity host, Surface surface) { 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 d64a44ac046..18e62b46315 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoPlayerTestRunner.java @@ -16,11 +16,13 @@ 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; import android.os.Looper; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultLoadControl; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.Format; @@ -43,6 +45,7 @@ import com.google.android.exoplayer2.util.MimeTypes; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -71,8 +74,8 @@ public static final class Builder { private Clock clock; private Timeline timeline; + private List mediaSources; private Object manifest; - private MediaSource mediaSource; private DefaultTrackSelector trackSelector; private LoadControl loadControl; private BandwidthMeter bandwidthMeter; @@ -83,19 +86,28 @@ public static final class Builder { private Player.EventListener eventListener; private AnalyticsListener analyticsListener; private Integer expectedPlayerEndedCount; + private boolean useLazyPreparation; + private int initialWindowIndex; + private long initialPositionMs; + + public Builder() { + mediaSources = new ArrayList<>(); + initialWindowIndex = C.INDEX_UNSET; + initialPositionMs = C.TIME_UNSET; + } /** * Sets a {@link Timeline} to be used by a {@link FakeMediaSource} in the test runner. The * default value is a seekable, non-dynamic {@link FakeTimeline} with a duration of {@link * FakeTimeline.TimelineWindowDefinition#DEFAULT_WINDOW_DURATION_US}. Setting the timeline is - * not allowed after a call to {@link #setMediaSource(MediaSource)}. + * not allowed after a call to {@link #setMediaSources(MediaSource...)}. * * @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; } @@ -103,30 +115,54 @@ public Builder setTimeline(Timeline timeline) { /** * Sets a manifest to be used by a {@link FakeMediaSource} in the test runner. The default value * is null. Setting the manifest is not allowed after a call to {@link - * #setMediaSource(MediaSource)}. + * #setMediaSources(MediaSource...)}. * * @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 + * Seeks before setting the media sources and preparing the player. + * + * @param windowIndex The window index to seek to. + * @param positionMs The position in milliseconds to seek to. + * @return This builder. + */ + public Builder initialSeek(int windowIndex, int positionMs) { + this.initialWindowIndex = windowIndex; + this.initialPositionMs = positionMs; + return this; + } + + /** + * Sets the {@link MediaSource}s to be used by the test runner. The default value is a {@link * FakeMediaSource} with the timeline and manifest provided by {@link #setTimeline(Timeline)} - * and {@link #setManifest(Object)}. Setting the media source is not allowed after calls to - * {@link #setTimeline(Timeline)} and/or {@link #setManifest(Object)}. + * and {@link #setManifest(Object)}. Setting media sources is not allowed after calls to {@link + * #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; + } + + /** + * Sets whether to use lazy preparation. + * + * @param useLazyPreparation Whether to use lazy preparation. + * @return This builder. + */ + public Builder setUseLazyPreparation(boolean useLazyPreparation) { + this.useLazyPreparation = useLazyPreparation; return this; } @@ -170,7 +206,7 @@ public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) { * Sets a list of {@link Format}s to be used by a {@link FakeMediaSource} to create media * periods and for setting up a {@link FakeRenderer}. The default value is a single {@link * #VIDEO_FORMAT}. Note that this parameter doesn't have any influence if both a media source - * with {@link #setMediaSource(MediaSource)} and renderers with {@link + * with {@link #setMediaSources(MediaSource...)} and renderers with {@link * #setRenderers(Renderer...)} or {@link #setRenderersFactory(RenderersFactory)} are set. * * @param supportedFormats A list of supported {@link Format}s. @@ -224,7 +260,7 @@ public Builder setClock(Clock clock) { /** * Sets an {@link ActionSchedule} to be run by the test runner. The first action will be - * executed immediately before {@link SimpleExoPlayer#prepare(MediaSource)}. + * executed immediately before {@link SimpleExoPlayer#prepare()}. * * @param actionSchedule An {@link ActionSchedule} to be used by the test runner. * @return This builder. @@ -305,11 +341,11 @@ public ExoPlayerTestRunner build(Context context) { 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; @@ -317,7 +353,10 @@ public ExoPlayerTestRunner build(Context context) { return new ExoPlayerTestRunner( context, clock, - mediaSource, + initialWindowIndex, + initialPositionMs, + mediaSources, + useLazyPreparation, renderersFactory, trackSelector, loadControl, @@ -331,7 +370,9 @@ public ExoPlayerTestRunner build(Context context) { private final Context context; private final Clock clock; - private final MediaSource mediaSource; + private final int initialWindowIndex; + private final long initialPositionMs; + private final List mediaSources; private final RenderersFactory renderersFactory; private final DefaultTrackSelector trackSelector; private final LoadControl loadControl; @@ -348,6 +389,8 @@ public ExoPlayerTestRunner build(Context context) { private final ArrayList timelineChangeReasons; private final ArrayList periodIndices; private final ArrayList discontinuityReasons; + private final ArrayList playbackStates; + private final boolean useLazyPreparation; private SimpleExoPlayer player; private Exception exception; @@ -357,7 +400,10 @@ public ExoPlayerTestRunner build(Context context) { private ExoPlayerTestRunner( Context context, Clock clock, - MediaSource mediaSource, + int initialWindowIndex, + long initialPositionMs, + List mediaSources, + boolean useLazyPreparation, RenderersFactory renderersFactory, DefaultTrackSelector trackSelector, LoadControl loadControl, @@ -368,7 +414,10 @@ private ExoPlayerTestRunner( int expectedPlayerEndedCount) { this.context = context; this.clock = clock; - this.mediaSource = mediaSource; + this.initialWindowIndex = initialWindowIndex; + this.initialPositionMs = initialPositionMs; + this.mediaSources = mediaSources; + this.useLazyPreparation = useLazyPreparation; this.renderersFactory = renderersFactory; this.trackSelector = trackSelector; this.loadControl = loadControl; @@ -380,6 +429,7 @@ private ExoPlayerTestRunner( this.timelineChangeReasons = new ArrayList<>(); this.periodIndices = new ArrayList<>(); this.discontinuityReasons = new ArrayList<>(); + this.playbackStates = new ArrayList<>(); this.endedCountDownLatch = new CountDownLatch(expectedPlayerEndedCount); this.actionScheduleFinishedCountDownLatch = new CountDownLatch(actionSchedule != null ? 1 : 0); this.playerThread = new HandlerThread("ExoPlayerTest thread"); @@ -418,6 +468,7 @@ public ExoPlayerTestRunner start(boolean doPrepare) { .setBandwidthMeter(bandwidthMeter) .setAnalyticsCollector(new AnalyticsCollector(clock)) .setClock(clock) + .setUseLazyPreparation(useLazyPreparation) .setLooper(Looper.myLooper()) .build(); player.addListener(ExoPlayerTestRunner.this); @@ -431,7 +482,13 @@ public ExoPlayerTestRunner start(boolean doPrepare) { if (actionSchedule != null) { actionSchedule.start(player, trackSelector, null, handler, ExoPlayerTestRunner.this); } - player.prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); + if (initialWindowIndex != C.INDEX_UNSET) { + player.seekTo(initialWindowIndex, initialPositionMs); + } + player.setMediaItems(mediaSources, /* resetPosition= */ false); + if (doPrepare) { + player.prepare(); + } } catch (Exception e) { handleException(e); } @@ -483,12 +540,16 @@ public ExoPlayerTestRunner blockUntilActionScheduleFinished(long timeoutMs) /** * Asserts that the timelines reported by {@link Player.EventListener#onTimelineChanged(Timeline, - * int)} are equal to the provided timelines. + * int)} are the same to the provided timelines. This assert differs from testing equality by not + * comparing period ids which may be different due to id mapping of child source period ids. * * @param timelines A list of expected {@link Timeline}s. */ - public void assertTimelinesEqual(Timeline... timelines) { - assertThat(this.timelines).containsExactlyElementsIn(Arrays.asList(timelines)).inOrder(); + public void assertTimelinesSame(Timeline... timelines) { + assertThat(this.timelines).hasSize(timelines.length); + for (int i = 0; i < timelines.length; i++) { + assertTrue(TestUtil.areTimelinesSame(timelines[i], this.timelines.get(i))); + } } /** @@ -500,6 +561,15 @@ public void assertTimelineChangeReasonsEqual(Integer... reasons) { assertThat(timelineChangeReasons).containsExactlyElementsIn(Arrays.asList(reasons)).inOrder(); } + /** + * Asserts that the playback states reported by {@link + * Player.EventListener#onPlayerStateChanged(boolean, int)} are equal to the provided playback + * states. + */ + public void assertPlaybackStatesEqual(Integer... states) { + assertThat(playbackStates).containsExactlyElementsIn(Arrays.asList(states)).inOrder(); + } + /** * Asserts that the last track group array reported by {@link * Player.EventListener#onTracksChanged(TrackGroupArray, TrackSelectionArray)} is equal to the @@ -575,10 +645,12 @@ private void handleException(Exception exception) { @Override public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { - timelines.add(timeline); timelineChangeReasons.add(reason); - if (reason == Player.TIMELINE_CHANGE_REASON_PREPARED) { - periodIndices.add(player.getCurrentPeriodIndex()); + timelines.add(timeline); + int currentIndex = player.getCurrentPeriodIndex(); + if (periodIndices.isEmpty() || periodIndices.get(periodIndices.size() - 1) != currentIndex) { + // Ignore timeline changes that do not change the period index. + periodIndices.add(currentIndex); } } @@ -589,6 +661,7 @@ public void onTracksChanged(TrackGroupArray trackGroups, TrackSelectionArray tra @Override public void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + playbackStates.add(playbackState); playerWasPrepared |= playbackState != Player.STATE_IDLE; if (playbackState == Player.STATE_ENDED || (playbackState == Player.STATE_IDLE && playerWasPrepared)) { diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java index 401fcf80340..c8c71900077 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeTimeline.java @@ -40,6 +40,7 @@ public static final class TimelineWindowDefinition { public final Object id; public final boolean isSeekable; public final boolean isDynamic; + public final boolean isLive; public final long durationUs; public final AdPlaybackState adPlaybackState; @@ -99,10 +100,41 @@ public TimelineWindowDefinition( boolean isDynamic, long durationUs, AdPlaybackState adPlaybackState) { + this( + periodCount, + id, + isSeekable, + isDynamic, + /* isLive= */ isDynamic, + durationUs, + adPlaybackState); + } + + /** + * Creates a window definition with ad groups. + * + * @param periodCount The number of periods in the window. Each period get an equal slice of the + * total window duration. + * @param id The UID of the window. + * @param isSeekable Whether the window is seekable. + * @param isDynamic Whether the window is dynamic. + * @param isLive Whether the window is live. + * @param durationUs The duration of the window in microseconds. + * @param adPlaybackState The ad playback state. + */ + public TimelineWindowDefinition( + int periodCount, + Object id, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + long durationUs, + AdPlaybackState adPlaybackState) { this.periodCount = periodCount; this.id = id; this.isSeekable = isSeekable; this.isDynamic = isDynamic; + this.isLive = isLive; this.durationUs = durationUs; this.adPlaybackState = adPlaybackState; } @@ -189,7 +221,7 @@ public Window getWindow(int windowIndex, Window window, long defaultPositionProj /* windowStartTimeMs= */ C.TIME_UNSET, windowDefinition.isSeekable, windowDefinition.isDynamic, - /* isLive= */ windowDefinition.isDynamic, + windowDefinition.isLive, /* defaultPositionUs= */ 0, windowDefinition.durationUs, periodOffsets[windowIndex], 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 18eaec2cd7a..a826e73e16d 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/StubExoPlayer.java @@ -25,8 +25,10 @@ import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; import com.google.android.exoplayer2.source.TrackGroupArray; import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import java.util.List; /** * An abstract {@link ExoPlayer} implementation that throws {@link UnsupportedOperationException} @@ -96,6 +98,11 @@ public void retry() { throw new UnsupportedOperationException(); } + @Override + public void prepare() { + throw new UnsupportedOperationException(); + } + @Override public void prepare(MediaSource mediaSource) { throw new UnsupportedOperationException(); @@ -106,6 +113,77 @@ public void prepare(MediaSource mediaSource, boolean resetPosition, boolean rese 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 int getRepeatMode() { 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 facfa0d7e4b..ae81204e4be 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.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,62 @@ public static ExtractorInput getExtractorInputFromPosition( } 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.isLive == second.isLive + && first.defaultPositionUs == second.defaultPositionUs + && first.durationUs == second.durationUs + && first.firstPeriodIndex == second.firstPeriodIndex + && first.lastPeriodIndex == second.lastPeriodIndex + && first.positionInFirstPeriodUs == second.positionInFirstPeriodUs; + } }