diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2.java new file mode 100644 index 00000000000..10af495e011 --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2.java @@ -0,0 +1,608 @@ +/* + * Copyright 2021 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.source; + +import static com.google.android.exoplayer2.util.Assertions.checkArgument; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Assertions.checkState; +import static com.google.android.exoplayer2.util.Assertions.checkStateNotNull; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.Message; +import android.util.Pair; +import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Util; +import com.google.common.collect.ImmutableList; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.IdentityHashMap; + +/** + * Concatenates multiple {@link MediaSource MediaSources}, combining everything in one single {@link + * Timeline.Window}. + * + *

This class can only be used under the following conditions: + * + *

+ */ +public final class ConcatenatingMediaSource2 extends CompositeMediaSource { + + /** A builder for {@link ConcatenatingMediaSource2} instances. */ + public static final class Builder { + + private final ImmutableList.Builder mediaSourceHoldersBuilder; + + private int index; + @Nullable private MediaItem mediaItem; + @Nullable private MediaSource.Factory mediaSourceFactory; + + /** Creates the builder. */ + public Builder() { + mediaSourceHoldersBuilder = ImmutableList.builder(); + } + + /** + * Instructs the builder to use a {@link DefaultMediaSourceFactory} to convert {@link MediaItem + * MediaItems} to {@link MediaSource MediaSources} for all future calls to {@link + * #add(MediaItem)} or {@link #add(MediaItem, long)}. + * + * @param context A {@link Context}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder useDefaultMediaSourceFactory(Context context) { + return setMediaSourceFactory(new DefaultMediaSourceFactory(context)); + } + + /** + * Sets a {@link MediaSource.Factory} that is used to convert {@link MediaItem MediaItems} to + * {@link MediaSource MediaSources} for all future calls to {@link #add(MediaItem)} or {@link + * #add(MediaItem, long)}. + * + * @param mediaSourceFactory A {@link MediaSource.Factory}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaSourceFactory(MediaSource.Factory mediaSourceFactory) { + this.mediaSourceFactory = checkNotNull(mediaSourceFactory); + return this; + } + + /** + * Sets the {@link MediaItem} to be used for the concatenated media source. + * + *

This {@link MediaItem} will be used as {@link Timeline.Window#mediaItem} for the + * concatenated source and will be returned by {@link Player#getCurrentMediaItem()}. + * + *

The default is {@code MediaItem.fromUri(Uri.EMPTY)}. + * + * @param mediaItem The {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setMediaItem(MediaItem mediaItem) { + this.mediaItem = mediaItem; + return this; + } + + /** + * Adds a {@link MediaItem} to the concatenation. + * + *

{@link #useDefaultMediaSourceFactory(Context)} or {@link + * #setMediaSourceFactory(MediaSource.Factory)} must be called before this method. + * + *

This method must not be used with media items for progressive media that can't provide + * their duration with their first {@link Timeline} update. Use {@link #add(MediaItem, long)} + * instead. + * + * @param mediaItem The {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaItem mediaItem) { + return add(mediaItem, /* initialPlaceholderDurationMs= */ C.TIME_UNSET); + } + + /** + * Adds a {@link MediaItem} to the concatenation and specifies its initial placeholder duration + * used while the actual duration is still unknown. + * + *

{@link #useDefaultMediaSourceFactory(Context)} or {@link + * #setMediaSourceFactory(MediaSource.Factory)} must be called before this method. + * + *

Setting a placeholder duration is required for media items for progressive media that + * can't provide their duration with their first {@link Timeline} update. It may also be used + * for other items to make the duration known immediately. + * + * @param mediaItem The {@link MediaItem}. + * @param initialPlaceholderDurationMs The initial placeholder duration in milliseconds used + * while the actual duration is still unknown, or {@link C#TIME_UNSET} to not define one. + * The placeholder duration is used for every {@link Timeline.Window} defined by {@link + * Timeline} of the {@link MediaItem}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaItem mediaItem, long initialPlaceholderDurationMs) { + checkNotNull(mediaItem); + checkStateNotNull( + mediaSourceFactory, + "Must use useDefaultMediaSourceFactory or setMediaSourceFactory first."); + return add(mediaSourceFactory.createMediaSource(mediaItem), initialPlaceholderDurationMs); + } + + /** + * Adds a {@link MediaSource} to the concatenation. + * + *

This method must not be used for sources like {@link ProgressiveMediaSource} that can't + * provide their duration with their first {@link Timeline} update. Use {@link #add(MediaSource, + * long)} instead. + * + * @param mediaSource The {@link MediaSource}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaSource mediaSource) { + return add(mediaSource, /* initialPlaceholderDurationMs= */ C.TIME_UNSET); + } + + /** + * Adds a {@link MediaSource} to the concatenation and specifies its initial placeholder + * duration used while the actual duration is still unknown. + * + *

Setting a placeholder duration is required for sources like {@link ProgressiveMediaSource} + * that can't provide their duration with their first {@link Timeline} update. It may also be + * used for other sources to make the duration known immediately. + * + * @param mediaSource The {@link MediaSource}. + * @param initialPlaceholderDurationMs The initial placeholder duration in milliseconds used + * while the actual duration is still unknown, or {@link C#TIME_UNSET} to not define one. + * The placeholder duration is used for every {@link Timeline.Window} defined by {@link + * Timeline} of the {@link MediaSource}. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder add(MediaSource mediaSource, long initialPlaceholderDurationMs) { + checkNotNull(mediaSource); + checkState( + !(mediaSource instanceof ProgressiveMediaSource) + || initialPlaceholderDurationMs != C.TIME_UNSET, + "Progressive media source must define an initial placeholder duration."); + mediaSourceHoldersBuilder.add( + new MediaSourceHolder(mediaSource, index++, Util.msToUs(initialPlaceholderDurationMs))); + return this; + } + + /** Builds the concatenating media source. */ + public ConcatenatingMediaSource2 build() { + checkArgument(index > 0, "Must add at least one source to the concatenation."); + if (mediaItem == null) { + mediaItem = MediaItem.fromUri(Uri.EMPTY); + } + return new ConcatenatingMediaSource2(mediaItem, mediaSourceHoldersBuilder.build()); + } + } + + private static final int MSG_UPDATE_TIMELINE = 0; + + private final MediaItem mediaItem; + private final ImmutableList mediaSourceHolders; + private final IdentityHashMap mediaSourceByMediaPeriod; + + @Nullable private Handler playbackThreadHandler; + private boolean timelineUpdateScheduled; + + private ConcatenatingMediaSource2( + MediaItem mediaItem, ImmutableList mediaSourceHolders) { + this.mediaItem = mediaItem; + this.mediaSourceHolders = mediaSourceHolders; + mediaSourceByMediaPeriod = new IdentityHashMap<>(); + } + + @Nullable + @Override + public Timeline getInitialTimeline() { + return maybeCreateConcatenatedTimeline(); + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + playbackThreadHandler = new Handler(/* callback= */ this::handleMessage); + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + prepareChildSource(/* id= */ i, holder.mediaSource); + } + scheduleTimelineUpdate(); + } + + @SuppressWarnings("MissingSuperCall") + @Override + protected void enableInternal() { + // Suppress enabling all child sources here as they can be lazily enabled when creating periods. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + int holderIndex = getChildIndex(id.periodUid); + MediaSourceHolder holder = mediaSourceHolders.get(holderIndex); + MediaPeriodId childMediaPeriodId = + id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)) + .copyWithWindowSequenceNumber( + getChildWindowSequenceNumber( + id.windowSequenceNumber, mediaSourceHolders.size(), holder.index)); + enableChildSource(holder.index); + holder.activeMediaPeriods++; + MediaPeriod mediaPeriod = + holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaSourceByMediaPeriod.put(mediaPeriod, holder); + disableUnusedMediaSources(); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MediaSourceHolder holder = checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + holder.mediaSource.releasePeriod(mediaPeriod); + holder.activeMediaPeriods--; + if (!mediaSourceByMediaPeriod.isEmpty()) { + disableUnusedMediaSources(); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + if (playbackThreadHandler != null) { + playbackThreadHandler.removeCallbacksAndMessages(null); + playbackThreadHandler = null; + } + timelineUpdateScheduled = false; + } + + @Override + protected void onChildSourceInfoRefreshed( + Integer childSourceId, MediaSource mediaSource, Timeline newTimeline) { + scheduleTimelineUpdate(); + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Integer childSourceId, MediaPeriodId mediaPeriodId) { + int childIndex = + getChildIndexFromChildWindowSequenceNumber( + mediaPeriodId.windowSequenceNumber, mediaSourceHolders.size()); + if (childSourceId != childIndex) { + // Ensure the reported media period id has the expected window sequence number. Otherwise it + // does not belong to this child source. + return null; + } + long windowSequenceNumber = + getWindowSequenceNumberFromChildWindowSequenceNumber( + mediaPeriodId.windowSequenceNumber, mediaSourceHolders.size()); + Object periodUid = getPeriodUid(childSourceId, mediaPeriodId.periodUid); + return mediaPeriodId + .copyWithPeriodUid(periodUid) + .copyWithWindowSequenceNumber(windowSequenceNumber); + } + + @Override + protected int getWindowIndexForChildWindowIndex(Integer childSourceId, int windowIndex) { + return 0; + } + + private boolean handleMessage(Message msg) { + if (msg.what == MSG_UPDATE_TIMELINE) { + updateTimeline(); + } + return true; + } + + private void scheduleTimelineUpdate() { + if (!timelineUpdateScheduled) { + checkNotNull(playbackThreadHandler).obtainMessage(MSG_UPDATE_TIMELINE).sendToTarget(); + timelineUpdateScheduled = true; + } + } + + private void updateTimeline() { + timelineUpdateScheduled = false; + @Nullable ConcatenatedTimeline timeline = maybeCreateConcatenatedTimeline(); + if (timeline != null) { + refreshSourceInfo(timeline); + } + } + + private void disableUnusedMediaSources() { + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + if (holder.activeMediaPeriods == 0) { + disableChildSource(holder.index); + } + } + } + + @Nullable + private ConcatenatedTimeline maybeCreateConcatenatedTimeline() { + Timeline.Window window = new Timeline.Window(); + Timeline.Period period = new Timeline.Period(); + ImmutableList.Builder timelinesBuilder = ImmutableList.builder(); + ImmutableList.Builder firstPeriodIndicesBuilder = ImmutableList.builder(); + ImmutableList.Builder periodOffsetsInWindowUsBuilder = ImmutableList.builder(); + int periodCount = 0; + boolean isSeekable = true; + boolean isDynamic = false; + long durationUs = 0; + long defaultPositionUs = 0; + long nextPeriodOffsetInWindowUs = 0; + boolean manifestsAreIdentical = true; + boolean hasInitialManifest = false; + @Nullable Object initialManifest = null; + for (int i = 0; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + Timeline timeline = holder.mediaSource.getTimeline(); + checkArgument(!timeline.isEmpty(), "Can't concatenate empty child Timeline."); + timelinesBuilder.add(timeline); + firstPeriodIndicesBuilder.add(periodCount); + periodCount += timeline.getPeriodCount(); + for (int j = 0; j < timeline.getWindowCount(); j++) { + timeline.getWindow(/* windowIndex= */ j, window); + if (!hasInitialManifest) { + initialManifest = window.manifest; + hasInitialManifest = true; + } + manifestsAreIdentical = + manifestsAreIdentical && Util.areEqual(initialManifest, window.manifest); + + long windowDurationUs = window.durationUs; + if (windowDurationUs == C.TIME_UNSET) { + if (holder.initialPlaceholderDurationUs == C.TIME_UNSET) { + // Source duration isn't known yet and we have no placeholder duration. + return null; + } + windowDurationUs = holder.initialPlaceholderDurationUs; + } + durationUs += windowDurationUs; + if (holder.index == 0 && j == 0) { + defaultPositionUs = window.defaultPositionUs; + nextPeriodOffsetInWindowUs = -window.positionInFirstPeriodUs; + } else { + checkArgument( + window.positionInFirstPeriodUs == 0, + "Can't concatenate windows. A window has a non-zero offset in a period."); + } + // Assume placeholder windows are seekable to not prevent seeking in other periods. + isSeekable &= window.isSeekable || window.isPlaceholder; + isDynamic |= window.isDynamic; + } + int childPeriodCount = timeline.getPeriodCount(); + for (int j = 0; j < childPeriodCount; j++) { + periodOffsetsInWindowUsBuilder.add(nextPeriodOffsetInWindowUs); + timeline.getPeriod(/* periodIndex= */ j, period); + long periodDurationUs = period.durationUs; + if (periodDurationUs == C.TIME_UNSET) { + checkArgument( + childPeriodCount == 1, + "Can't concatenate multiple periods with unknown duration in one window."); + long windowDurationUs = + window.durationUs != C.TIME_UNSET + ? window.durationUs + : holder.initialPlaceholderDurationUs; + periodDurationUs = windowDurationUs + window.positionInFirstPeriodUs; + } + nextPeriodOffsetInWindowUs += periodDurationUs; + } + } + return new ConcatenatedTimeline( + mediaItem, + timelinesBuilder.build(), + firstPeriodIndicesBuilder.build(), + periodOffsetsInWindowUsBuilder.build(), + isSeekable, + isDynamic, + durationUs, + defaultPositionUs, + manifestsAreIdentical ? initialManifest : null); + } + + /** + * Returns the period uid for the concatenated source from the child index and child period uid. + */ + private static Object getPeriodUid(int childIndex, Object childPeriodUid) { + return Pair.create(childIndex, childPeriodUid); + } + + /** Returns the child index from the period uid of the concatenated source. */ + @SuppressWarnings("unchecked") + private static int getChildIndex(Object periodUid) { + return ((Pair) periodUid).first; + } + + /** Returns the uid of child period from the period uid of the concatenated source. */ + @SuppressWarnings("unchecked") + private static Object getChildPeriodUid(Object periodUid) { + return ((Pair) periodUid).second; + } + + /** Returns the window sequence number used for the child source. */ + private static long getChildWindowSequenceNumber( + long windowSequenceNumber, int childCount, int childIndex) { + return windowSequenceNumber * childCount + childIndex; + } + + /** Returns the index of the child source from a child window sequence number. */ + private static int getChildIndexFromChildWindowSequenceNumber( + long childWindowSequenceNumber, int childCount) { + return (int) (childWindowSequenceNumber % childCount); + } + + /** Returns the concatenated window sequence number from a child window sequence number. */ + private static long getWindowSequenceNumberFromChildWindowSequenceNumber( + long childWindowSequenceNumber, int childCount) { + return childWindowSequenceNumber / childCount; + } + + /* package */ static final class MediaSourceHolder { + + public final MaskingMediaSource mediaSource; + public final int index; + public final long initialPlaceholderDurationUs; + + public int activeMediaPeriods; + + public MediaSourceHolder( + MediaSource mediaSource, int index, long initialPlaceholderDurationUs) { + this.mediaSource = new MaskingMediaSource(mediaSource, /* useLazyPreparation= */ false); + this.index = index; + this.initialPlaceholderDurationUs = initialPlaceholderDurationUs; + } + } + + private static final class ConcatenatedTimeline extends Timeline { + + private final MediaItem mediaItem; + private final ImmutableList timelines; + private final ImmutableList firstPeriodIndices; + private final ImmutableList periodOffsetsInWindowUs; + private final boolean isSeekable; + private final boolean isDynamic; + private final long durationUs; + private final long defaultPositionUs; + @Nullable private final Object manifest; + + public ConcatenatedTimeline( + MediaItem mediaItem, + ImmutableList timelines, + ImmutableList firstPeriodIndices, + ImmutableList periodOffsetsInWindowUs, + boolean isSeekable, + boolean isDynamic, + long durationUs, + long defaultPositionUs, + @Nullable Object manifest) { + this.mediaItem = mediaItem; + this.timelines = timelines; + this.firstPeriodIndices = firstPeriodIndices; + this.periodOffsetsInWindowUs = periodOffsetsInWindowUs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.durationUs = durationUs; + this.defaultPositionUs = defaultPositionUs; + this.manifest = manifest; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public int getPeriodCount() { + return periodOffsetsInWindowUs.size(); + } + + @Override + public final Window getWindow( + int windowIndex, Window window, long defaultPositionProjectionUs) { + return window.set( + Window.SINGLE_WINDOW_UID, + mediaItem, + manifest, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* elapsedRealtimeEpochOffsetMs= */ C.TIME_UNSET, + isSeekable, + isDynamic, + /* liveConfiguration= */ null, + defaultPositionUs, + durationUs, + /* firstPeriodIndex= */ 0, + /* lastPeriodIndex= */ getPeriodCount() - 1, + /* positionInFirstPeriodUs= */ -periodOffsetsInWindowUs.get(0)); + } + + @Override + public final Period getPeriodByUid(Object periodUid, Period period) { + int childIndex = getChildIndex(periodUid); + Object childPeriodUid = getChildPeriodUid(periodUid); + Timeline timeline = timelines.get(childIndex); + int periodIndex = + firstPeriodIndices.get(childIndex) + timeline.getIndexOfPeriod(childPeriodUid); + timeline.getPeriodByUid(childPeriodUid, period); + period.windowIndex = 0; + period.positionInWindowUs = periodOffsetsInWindowUs.get(periodIndex); + period.uid = periodUid; + return period; + } + + @Override + public final Period getPeriod(int periodIndex, Period period, boolean setIds) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = firstPeriodIndices.get(childIndex); + timelines.get(childIndex).getPeriod(periodIndex - firstPeriodIndexInChild, period, setIds); + period.windowIndex = 0; + period.positionInWindowUs = periodOffsetsInWindowUs.get(periodIndex); + if (setIds) { + period.uid = getPeriodUid(childIndex, checkNotNull(period.uid)); + } + return period; + } + + @Override + public final int getIndexOfPeriod(Object uid) { + if (!(uid instanceof Pair) || !(((Pair) uid).first instanceof Integer)) { + return C.INDEX_UNSET; + } + int childIndex = getChildIndex(uid); + Object periodUid = getChildPeriodUid(uid); + int periodIndexInChild = timelines.get(childIndex).getIndexOfPeriod(periodUid); + return periodIndexInChild == C.INDEX_UNSET + ? C.INDEX_UNSET + : firstPeriodIndices.get(childIndex) + periodIndexInChild; + } + + @Override + public final Object getUidOfPeriod(int periodIndex) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = firstPeriodIndices.get(childIndex); + Object periodUidInChild = + timelines.get(childIndex).getUidOfPeriod(periodIndex - firstPeriodIndexInChild); + return getPeriodUid(childIndex, periodUidInChild); + } + + private int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor( + firstPeriodIndices, periodIndex + 1, /* inclusive= */ false, /* stayInBounds= */ false); + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2Test.java b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2Test.java new file mode 100644 index 00000000000..746ae1a3705 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource2Test.java @@ -0,0 +1,911 @@ +/* + * Copyright 2021 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.source; + +import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil; +import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPlaybackState; +import static com.google.common.truth.Truth.assertThat; +import static java.lang.Math.max; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.Looper; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.test.core.app.ApplicationProvider; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.analytics.PlayerId; +import com.google.android.exoplayer2.source.ads.AdPlaybackState; +import com.google.android.exoplayer2.testutil.FakeMediaSource; +import com.google.android.exoplayer2.testutil.FakeTimeline; +import com.google.android.exoplayer2.testutil.TestExoPlayerBuilder; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.EventLogger; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Supplier; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; + +/** Unit tests for {@link ConcatenatingMediaSource2}. */ +@RunWith(ParameterizedRobolectricTestRunner.class) +public final class ConcatenatingMediaSource2Test { + + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + public static ImmutableList params() { + ImmutableList.Builder builder = ImmutableList.builder(); + + // Full example with an offset in the initial window, MediaSource with multiple windows and + // periods, and sources with ad insertion. + AdPlaybackState adPlaybackState = + new AdPlaybackState(/* adsId= */ 123, /* adGroupTimesUs...= */ 0, 300_000) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdDurationsUs(new long[][] {new long[] {2_000_000}, new long[] {4_000_000}}); + builder.add( + new TestConfig( + "initial_offset_multiple_windows_and_ads", + buildConcatenatingMediaSource( + buildMediaSource( + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50), + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 2500)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 500, + adPlaybackState)), + buildMediaSource( + buildWindow( + /* periodCount= */ 3, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1800))), + /* expectedAdDiscontinuities= */ 3, + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {550, 500, 1250, 1250, 500, 600, 600, 600}, + /* periodOffsetsInWindowMs= */ new long[] { + -50, 500, 1000, 2250, 3500, 4000, 4600, 5200 + }, + /* periodIsPlaceholder= */ new boolean[] { + false, false, false, false, false, false, false, false + }, + /* windowDurationMs= */ 5800, + /* manifest= */ null) + .withAdPlaybackState(/* periodIndex= */ 4, adPlaybackState))); + + builder.add( + new TestConfig( + "multipleMediaSource_sameManifest", + buildConcatenatingMediaSource( + buildMediaSource( + new Object[] {"manifest"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000)), + buildMediaSource( + new Object[] {"manifest"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 1000}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 2000, + /* manifest= */ "manifest"))); + + builder.add( + new TestConfig( + "multipleMediaSource_differentManifest", + buildConcatenatingMediaSource( + buildMediaSource( + new Object[] {"manifest1"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000)), + buildMediaSource( + new Object[] {"manifest2"}, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 1000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 1000}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 2000, + /* manifest= */ null))); + + // Counter-example for isSeekable and isDynamic. + builder.add( + new TestConfig( + "isSeekable_isDynamic_counter_example", + buildConcatenatingMediaSource( + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ 500))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {1000, 500}, + /* periodOffsetsInWindowMs= */ new long[] {0, 1000}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 1500, + /* manifest= */ null))); + + // Unknown window and period durations. + builder.add( + new TestConfig( + "unknown_window_and_period_durations", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 420, + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ true, + /* durationMs= */ C.TIME_UNSET, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ C.TIME_UNSET))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 420}, + /* periodIsPlaceholder= */ new boolean[] {true, true}, + /* windowDurationMs= */ 840, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 420}, + /* periodIsPlaceholder= */ new boolean[] {false, false}, + /* windowDurationMs= */ 840, + /* manifest= */ null))); + + // Duplicate sources and nested concatenation. + builder.add( + new TestConfig( + "duplicated_and_nested_sources", + () -> { + MediaSource duplicateSource = + buildMediaSource( + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 1000)) + .get(); + Supplier duplicateSourceSupplier = () -> duplicateSource; + return buildConcatenatingMediaSource( + duplicateSourceSupplier, + buildConcatenatingMediaSource( + duplicateSourceSupplier, duplicateSourceSupplier), + buildConcatenatingMediaSource( + duplicateSourceSupplier, duplicateSourceSupplier), + duplicateSourceSupplier) + .get(); + }, + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ false, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] { + 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500 + }, + /* periodOffsetsInWindowMs= */ new long[] { + 0, 500, 1000, 1500, 2000, 2500, 3000, 3500, 4000, 4500, 5000, 5500 + }, + /* periodIsPlaceholder= */ new boolean[] { + false, false, false, false, false, false, false, false, false, false, false, false + }, + /* windowDurationMs= */ 6000, + /* manifest= */ null))); + + // Concatenation with initial placeholder durations and delayed timeline updates. + builder.add( + new TestConfig( + "initial_placeholder_and_delayed_preparation", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 5000, + buildMediaSource( + /* preparationDelayCount= */ 1, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 4000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + /* preparationDelayCount= */ 3, + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 7000)), + buildMediaSource( + /* preparationDelayCount= */ 2, + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 6000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 5000, 10000}, + /* periodIsPlaceholder= */ new boolean[] {true, true, true}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, true}, + /* windowDurationMs= */ 14000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, false}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, 3500, 3500, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 7500, 11000}, + /* periodIsPlaceholder= */ new boolean[] {false, false, false, false}, + /* windowDurationMs= */ 17000, + /* manifest= */ null))); + + // Concatenation with initial placeholder durations and some immediate timeline updates. + builder.add( + new TestConfig( + "initial_placeholder_and_immediate_partial_preparation", + buildConcatenatingMediaSource( + /* placeholderDurationMs= */ 5000, + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 4000, + /* defaultPositionMs= */ 123, + /* windowOffsetInFirstPeriodMs= */ 50)), + buildMediaSource( + /* preparationDelayCount= */ 1, + buildWindow( + /* periodCount= */ 2, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* durationMs= */ 7000)), + buildMediaSource( + buildWindow( + /* periodCount= */ 1, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* durationMs= */ 6000))), + /* expectedAdDiscontinuities= */ 0, + new ExpectedTimelineData( + /* isSeekable= */ true, + /* isDynamic= */ true, + /* defaultPositionMs= */ 0, + /* periodDurationsMs= */ new long[] {C.TIME_UNSET, C.TIME_UNSET, C.TIME_UNSET}, + /* periodOffsetsInWindowMs= */ new long[] {0, 5000, 10000}, + /* periodIsPlaceholder= */ new boolean[] {true, true, true}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ true, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, C.TIME_UNSET, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 9000}, + /* periodIsPlaceholder= */ new boolean[] {false, true, false}, + /* windowDurationMs= */ 15000, + /* manifest= */ null), + new ExpectedTimelineData( + /* isSeekable= */ false, + /* isDynamic= */ false, + /* defaultPositionMs= */ 123, + /* periodDurationsMs= */ new long[] {4050, 3500, 3500, 6000}, + /* periodOffsetsInWindowMs= */ new long[] {-50, 4000, 7500, 11000}, + /* periodIsPlaceholder= */ new boolean[] {false, false, false, false}, + /* windowDurationMs= */ 17000, + /* manifest= */ null))); + return builder.build(); + } + + @ParameterizedRobolectricTestRunner.Parameter public TestConfig config; + + private static final String TEST_MEDIA_ITEM_ID = "test_media_item_id"; + + @Test + public void prepareSource_reportsExpectedTimelines() throws Exception { + MediaSource mediaSource = config.mediaSourceSupplier.get(); + ArrayList timelines = new ArrayList<>(); + mediaSource.prepareSource( + (source, timeline) -> timelines.add(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + for (int i = 0; i < config.expectedTimelineData.size(); i++) { + Timeline timeline = timelines.get(i); + ExpectedTimelineData expectedData = config.expectedTimelineData.get(i); + assertThat(timeline.getWindowCount()).isEqualTo(1); + assertThat(timeline.getPeriodCount()).isEqualTo(expectedData.periodDurationsMs.length); + + Timeline.Window window = timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()); + assertThat(window.getDurationMs()).isEqualTo(expectedData.windowDurationMs); + assertThat(window.isDynamic).isEqualTo(expectedData.isDynamic); + assertThat(window.isSeekable).isEqualTo(expectedData.isSeekable); + assertThat(window.getDefaultPositionMs()).isEqualTo(expectedData.defaultPositionMs); + assertThat(window.getPositionInFirstPeriodMs()) + .isEqualTo(-expectedData.periodOffsetsInWindowMs[0]); + assertThat(window.firstPeriodIndex).isEqualTo(0); + assertThat(window.lastPeriodIndex).isEqualTo(expectedData.periodDurationsMs.length - 1); + assertThat(window.uid).isEqualTo(Timeline.Window.SINGLE_WINDOW_UID); + assertThat(window.mediaItem.mediaId).isEqualTo(TEST_MEDIA_ITEM_ID); + assertThat(window.isPlaceholder).isFalse(); + assertThat(window.elapsedRealtimeEpochOffsetMs).isEqualTo(C.TIME_UNSET); + assertThat(window.presentationStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.windowStartTimeMs).isEqualTo(C.TIME_UNSET); + assertThat(window.liveConfiguration).isNull(); + assertThat(window.manifest).isEqualTo(expectedData.manifest); + + HashSet uidSet = new HashSet<>(); + for (int j = 0; j < timeline.getPeriodCount(); j++) { + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ j, new Timeline.Period(), /* setIds= */ true); + assertThat(period.getDurationMs()).isEqualTo(expectedData.periodDurationsMs[j]); + assertThat(period.windowIndex).isEqualTo(0); + assertThat(period.getPositionInWindowMs()) + .isEqualTo(expectedData.periodOffsetsInWindowMs[j]); + assertThat(period.isPlaceholder).isEqualTo(expectedData.periodIsPlaceholder[j]); + uidSet.add(period.uid); + assertThat(timeline.getIndexOfPeriod(period.uid)).isEqualTo(j); + assertThat(timeline.getUidOfPeriod(j)).isEqualTo(period.uid); + assertThat(timeline.getPeriodByUid(period.uid, new Timeline.Period())).isEqualTo(period); + } + assertThat(uidSet).hasSize(timeline.getPeriodCount()); + } + } + + @Test + public void prepareSource_afterRelease_reportsSameFinalTimeline() throws Exception { + // Fully prepare source once. + MediaSource mediaSource = config.mediaSourceSupplier.get(); + ArrayList timelines = new ArrayList<>(); + MediaSource.MediaSourceCaller caller = (source, timeline) -> timelines.add(timeline); + mediaSource.prepareSource(caller, /* mediaTransferListener= */ null, PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + // Release and re-prepare. + mediaSource.releaseSource(caller); + AtomicReference secondTimeline = new AtomicReference<>(); + MediaSource.MediaSourceCaller secondCaller = (source, timeline) -> secondTimeline.set(timeline); + mediaSource.prepareSource(secondCaller, /* mediaTransferListener= */ null, PlayerId.UNSET); + + // Assert that we receive the same final timeline. + runMainLooperUntil(() -> Iterables.getLast(timelines).equals(secondTimeline.get())); + } + + @Test + public void preparePeriod_reportsExpectedPeriodLoadEvents() throws Exception { + // Prepare source and register listener. + MediaSource mediaSource = config.mediaSourceSupplier.get(); + MediaSourceEventListener eventListener = mock(MediaSourceEventListener.class); + mediaSource.addEventListener(new Handler(Looper.myLooper()), eventListener); + ArrayList timelines = new ArrayList<>(); + mediaSource.prepareSource( + (source, timeline) -> timelines.add(timeline), + /* mediaTransferListener= */ null, + PlayerId.UNSET); + runMainLooperUntil(() -> timelines.size() == config.expectedTimelineData.size()); + + // Iterate through all periods and ads. Create and prepare them twice, because the MediaSource + // should support creating the same period more than once. + ArrayList mediaPeriods = new ArrayList<>(); + ArrayList mediaPeriodIds = new ArrayList<>(); + Timeline timeline = Iterables.getLast(timelines); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + Timeline.Period period = + timeline.getPeriod(/* periodIndex= */ i, new Timeline.Period(), /* setIds= */ true); + MediaSource.MediaPeriodId mediaPeriodId = + new MediaSource.MediaPeriodId(period.uid, /* windowSequenceNumber= */ 15); + MediaPeriod mediaPeriod = + mediaSource.createPeriod(mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + mediaPeriodId = mediaPeriodId.copyWithWindowSequenceNumber(/* windowSequenceNumber= */ 25); + mediaPeriod = + mediaSource.createPeriod(mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + for (int j = 0; j < period.getAdGroupCount(); j++) { + for (int k = 0; k < period.getAdCountInAdGroup(j); k++) { + mediaPeriodId = + new MediaSource.MediaPeriodId( + period.uid, + /* adGroupIndex= */ j, + /* adIndexInAdGroup= */ k, + /* windowSequenceNumber= */ 35); + mediaPeriod = + mediaSource.createPeriod( + mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + + mediaPeriodId = + mediaPeriodId.copyWithWindowSequenceNumber(/* windowSequenceNumber= */ 45); + mediaPeriod = + mediaSource.createPeriod( + mediaPeriodId, /* allocator= */ null, /* startPositionUs= */ 0); + blockingPrepareMediaPeriod(mediaPeriod); + mediaPeriods.add(mediaPeriod); + mediaPeriodIds.add(mediaPeriodId); + } + } + } + // Release all periods again. + for (MediaPeriod mediaPeriod : mediaPeriods) { + mediaSource.releasePeriod(mediaPeriod); + } + + // Verify each load started and completed event is called with the correct mediaPeriodId. + for (MediaSource.MediaPeriodId mediaPeriodId : mediaPeriodIds) { + verify(eventListener) + .onLoadStarted( + /* windowIndex= */ eq(0), + /* mediaPeriodId= */ eq(mediaPeriodId), + /* loadEventInfo= */ any(), + /* mediaLoadData= */ any()); + verify(eventListener) + .onLoadCompleted( + /* windowIndex= */ eq(0), + /* mediaPeriodId= */ eq(mediaPeriodId), + /* loadEventInfo= */ any(), + /* mediaLoadData= */ any()); + } + } + + @Test + public void playback_fromDefaultPosition_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + player.setMediaSource(config.mediaSourceSupplier.get()); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + long positionAfterPrepareMs = player.getCurrentPosition(); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(positionAfterPrepareMs).isEqualTo(expectedData.defaultPositionMs); + if (!isDynamic) { + verify( + eventListener, + times(config.expectedAdDiscontinuities + expectedData.periodDurationsMs.length - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } + + @Test + public void + playback_fromSpecificPeriodPositionInFirstPeriod_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + player.setMediaSource(mediaSource); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + long startWindowPositionMs = 24; + player.seekTo(startWindowPositionMs); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + long windowPositionAfterPrepareMs = player.getCurrentPosition(); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(windowPositionAfterPrepareMs).isEqualTo(startWindowPositionMs); + if (!isDynamic) { + verify( + eventListener, + times(expectedData.periodDurationsMs.length - 1 + config.expectedAdDiscontinuities)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } + + @Test + public void + playback_fromSpecificPeriodPositionInSubsequentPeriod_startsFromCorrectPositionAndPlaysToEnd() + throws Exception { + Timeline.Period period = new Timeline.Period(); + Timeline.Window window = new Timeline.Window(); + ExoPlayer player = + new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build(); + MediaSource mediaSource = config.mediaSourceSupplier.get(); + player.setMediaSource(mediaSource); + Player.Listener eventListener = mock(Player.Listener.class); + player.addListener(eventListener); + player.addAnalyticsListener(new EventLogger()); + + ExpectedTimelineData initialTimelineData = config.expectedTimelineData.get(0); + int startPeriodIndex = max(1, initialTimelineData.periodDurationsMs.length - 2); + long startPeriodPositionMs = 24; + long startWindowPositionMs = + initialTimelineData.periodOffsetsInWindowMs[startPeriodIndex] + startPeriodPositionMs; + player.seekTo(startWindowPositionMs); + player.prepare(); + runUntilPlaybackState(player, Player.STATE_READY); + Timeline timeline = player.getCurrentTimeline(); + long windowPositionAfterPrepareMs = player.getContentPosition(); + Pair periodPositionUs = + timeline.getPeriodPositionUs(window, period, 0, Util.msToUs(windowPositionAfterPrepareMs)); + int periodIndexAfterPrepare = timeline.getIndexOfPeriod(periodPositionUs.first); + long periodPositionAfterPrepareMs = Util.usToMs(periodPositionUs.second); + boolean isDynamic = player.isCurrentMediaItemDynamic(); + if (!isDynamic) { + // Dynamic streams never enter the ENDED state. + player.play(); + runUntilPlaybackState(player, Player.STATE_ENDED); + } + player.release(); + + ExpectedTimelineData expectedData = Iterables.getLast(config.expectedTimelineData); + assertThat(periodPositionAfterPrepareMs).isEqualTo(startPeriodPositionMs); + if (timeline.getPeriod(periodIndexAfterPrepare, period).getAdGroupCount() == 0) { + assertThat(periodIndexAfterPrepare).isEqualTo(startPeriodIndex); + if (!isDynamic) { + verify(eventListener, times(expectedData.periodDurationsMs.length - startPeriodIndex - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + } + } else { + // Seek beyond ad period: assert roll forward to un-played ad period. + assertThat(periodIndexAfterPrepare).isLessThan(startPeriodIndex); + verify(eventListener, atLeast(expectedData.periodDurationsMs.length - startPeriodIndex - 1)) + .onPositionDiscontinuity(any(), any(), eq(Player.DISCONTINUITY_REASON_AUTO_TRANSITION)); + timeline.getPeriod(periodIndexAfterPrepare, period); + assertThat(period.getAdGroupIndexForPositionUs(period.durationUs)) + .isNotEqualTo(C.INDEX_UNSET); + } + } + + private static void blockingPrepareMediaPeriod(MediaPeriod mediaPeriod) { + ConditionVariable mediaPeriodPrepared = new ConditionVariable(); + mediaPeriod.prepare( + new MediaPeriod.Callback() { + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + mediaPeriodPrepared.open(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + mediaPeriod.continueLoading(/* positionUs= */ 0); + } + }, + /* positionUs= */ 0); + mediaPeriodPrepared.block(); + } + + private static Supplier buildConcatenatingMediaSource( + Supplier... sources) { + return buildConcatenatingMediaSource(/* placeholderDurationMs= */ C.TIME_UNSET, sources); + } + + private static Supplier buildConcatenatingMediaSource( + long placeholderDurationMs, Supplier... sources) { + return () -> { + ConcatenatingMediaSource2.Builder builder = new ConcatenatingMediaSource2.Builder(); + builder.setMediaItem(new MediaItem.Builder().setMediaId(TEST_MEDIA_ITEM_ID).build()); + for (Supplier source : sources) { + builder.add(source.get(), placeholderDurationMs); + } + return builder.build(); + }; + } + + private static Supplier buildMediaSource( + FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(/* preparationDelayCount= */ 0, windows); + } + + private static Supplier buildMediaSource( + int preparationDelayCount, FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(preparationDelayCount, /* manifests= */ null, windows); + } + + private static Supplier buildMediaSource( + Object[] manifests, FakeTimeline.TimelineWindowDefinition... windows) { + return buildMediaSource(/* preparationDelayCount= */ 0, manifests, windows); + } + + private static Supplier buildMediaSource( + int preparationDelayCount, + @Nullable Object[] manifests, + FakeTimeline.TimelineWindowDefinition... windows) { + + return () -> { + // Simulate delay by repeatedly sending messages to self. This ensures that all other message + // handling trigger by source preparation finishes before the new timeline update arrives. + AtomicInteger delayCount = new AtomicInteger(10 * preparationDelayCount); + return new FakeMediaSource( + /* timeline= */ null, + new Format.Builder().setSampleMimeType(MimeTypes.VIDEO_H264).build()) { + @Override + public synchronized void prepareSourceInternal( + @Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + Handler delayHandler = new Handler(Looper.myLooper()); + Runnable handleDelay = + new Runnable() { + @Override + public void run() { + if (delayCount.getAndDecrement() == 0) { + setNewSourceInfo( + manifests != null + ? new FakeTimeline(manifests, windows) + : new FakeTimeline(windows)); + } else { + delayHandler.post(this); + } + } + }; + delayHandler.post(handleDelay); + } + }; + }; + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, boolean isSeekable, boolean isDynamic, long durationMs) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + /* defaultPositionMs= */ 0, + /* windowOffsetInFirstPeriodMs= */ 0); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + long defaultPositionMs, + long windowOffsetInFirstPeriodMs) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + defaultPositionMs, + windowOffsetInFirstPeriodMs, + AdPlaybackState.NONE); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + AdPlaybackState adPlaybackState) { + return buildWindow( + periodCount, + isSeekable, + isDynamic, + durationMs, + /* defaultPositionMs= */ 0, + /* windowOffsetInFirstPeriodMs= */ 0, + adPlaybackState); + } + + private static FakeTimeline.TimelineWindowDefinition buildWindow( + int periodCount, + boolean isSeekable, + boolean isDynamic, + long durationMs, + long defaultPositionMs, + long windowOffsetInFirstPeriodMs, + AdPlaybackState adPlaybackState) { + return new FakeTimeline.TimelineWindowDefinition( + periodCount, + /* id= */ new Object(), + isSeekable, + isDynamic, + /* isLive= */ false, + /* isPlaceholder= */ false, + Util.msToUs(durationMs), + Util.msToUs(defaultPositionMs), + Util.msToUs(windowOffsetInFirstPeriodMs), + ImmutableList.of(adPlaybackState), + new MediaItem.Builder().setMediaId("").build()); + } + + private static final class TestConfig { + + public final Supplier mediaSourceSupplier; + public final ImmutableList expectedTimelineData; + + private final int expectedAdDiscontinuities; + private final String tag; + + public TestConfig( + String tag, + Supplier mediaSourceSupplier, + int expectedAdDiscontinuities, + ExpectedTimelineData... expectedTimelineData) { + this.tag = tag; + this.mediaSourceSupplier = mediaSourceSupplier; + this.expectedTimelineData = ImmutableList.copyOf(expectedTimelineData); + this.expectedAdDiscontinuities = expectedAdDiscontinuities; + } + + @Override + public String toString() { + return tag; + } + } + + private static final class ExpectedTimelineData { + + public final boolean isSeekable; + public final boolean isDynamic; + public final long defaultPositionMs; + public final long[] periodDurationsMs; + public final long[] periodOffsetsInWindowMs; + public final boolean[] periodIsPlaceholder; + public final long windowDurationMs; + public final AdPlaybackState[] adPlaybackState; + @Nullable public final Object manifest; + + public ExpectedTimelineData( + boolean isSeekable, + boolean isDynamic, + long defaultPositionMs, + long[] periodDurationsMs, + long[] periodOffsetsInWindowMs, + boolean[] periodIsPlaceholder, + long windowDurationMs, + @Nullable Object manifest) { + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.defaultPositionMs = defaultPositionMs; + this.periodDurationsMs = periodDurationsMs; + this.periodOffsetsInWindowMs = periodOffsetsInWindowMs; + this.periodIsPlaceholder = periodIsPlaceholder; + this.windowDurationMs = windowDurationMs; + this.adPlaybackState = new AdPlaybackState[periodDurationsMs.length]; + this.manifest = manifest; + } + + @CanIgnoreReturnValue + public ExpectedTimelineData withAdPlaybackState( + int periodIndex, AdPlaybackState adPlaybackState) { + this.adPlaybackState[periodIndex] = adPlaybackState; + return this; + } + } +}