Publish ConcatenatingMediaSource2

Can be used to combine multiple media items into a single timeline window.

Issue: androidx/media#247
Issue: google/ExoPlayer#4868
PiperOrigin-RevId: 506283307
This commit is contained in:
bachinger 2023-02-01 13:06:28 +00:00 committed by christosts
parent c6569a36fb
commit fcd3af6431
3 changed files with 1524 additions and 0 deletions

View File

@ -20,6 +20,9 @@
for seeking.
* Use theme when loading drawables on API 21+
([#220](https://github.com/androidx/media/issues/220)).
* Add `ConcatenatingMediaSource2` that allows combining multiple media
items into a single window
([#247](https://github.com/androidx/media/issues/247)).
* Extractors:
* Throw a ParserException instead of a NullPointerException if the sample
table (stbl) is missing a required sample description (stsd) when

View File

@ -0,0 +1,610 @@
/*
* 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 androidx.media3.exoplayer.source;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.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 androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.upstream.Allocator;
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}.
*
* <p>This class can only be used under the following conditions:
*
* <ul>
* <li>All sources must be non-empty.
* <li>All {@link Timeline.Window Windows} defined by the sources, except the first, must have an
* {@link Timeline.Window#getPositionInFirstPeriodUs() period offset} of zero. This excludes,
* for example, live streams or {@link ClippingMediaSource} with a non-zero start position.
* </ul>
*/
@UnstableApi
public final class ConcatenatingMediaSource2 extends CompositeMediaSource<Integer> {
/** A builder for {@link ConcatenatingMediaSource2} instances. */
public static final class Builder {
private final ImmutableList.Builder<MediaSourceHolder> 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.
*
* <p>This {@link MediaItem} will be used as {@link Timeline.Window#mediaItem} for the
* concatenated source and will be returned by {@link Player#getCurrentMediaItem()}.
*
* <p>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.
*
* <p>{@link #useDefaultMediaSourceFactory(Context)} or {@link
* #setMediaSourceFactory(MediaSource.Factory)} must be called before this method.
*
* <p>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.
*
* <p>{@link #useDefaultMediaSourceFactory(Context)} or {@link
* #setMediaSourceFactory(MediaSource.Factory)} must be called before this method.
*
* <p>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.
*
* <p>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.
*
* <p>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<MediaSourceHolder> mediaSourceHolders;
private final IdentityHashMap<MediaPeriod, MediaSourceHolder> mediaSourceByMediaPeriod;
@Nullable private Handler playbackThreadHandler;
private boolean timelineUpdateScheduled;
private ConcatenatingMediaSource2(
MediaItem mediaItem, ImmutableList<MediaSourceHolder> 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<Timeline> timelinesBuilder = ImmutableList.builder();
ImmutableList.Builder<Integer> firstPeriodIndicesBuilder = ImmutableList.builder();
ImmutableList.Builder<Long> 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<Integer, Object>) 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<Integer, Object>) 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<Timeline> timelines;
private final ImmutableList<Integer> firstPeriodIndices;
private final ImmutableList<Long> 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<Timeline> timelines,
ImmutableList<Integer> firstPeriodIndices,
ImmutableList<Long> 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);
}
}
}

View File

@ -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 androidx.media3.exoplayer.source;
import static androidx.media3.test.utils.robolectric.RobolectricUtil.runMainLooperUntil;
import static androidx.media3.test.utils.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.media3.common.AdPlaybackState;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Player;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.util.EventLogger;
import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeTimeline;
import androidx.media3.test.utils.TestExoPlayerBuilder;
import androidx.test.core.app.ApplicationProvider;
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<TestConfig> params() {
ImmutableList.Builder<TestConfig> 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<MediaSource> 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<Timeline> 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<Object> 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<Timeline> 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<Timeline> 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<Timeline> 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<MediaPeriod> mediaPeriods = new ArrayList<>();
ArrayList<MediaSource.MediaPeriodId> 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<Object, Long> 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<MediaSource> buildConcatenatingMediaSource(
Supplier<MediaSource>... sources) {
return buildConcatenatingMediaSource(/* placeholderDurationMs= */ C.TIME_UNSET, sources);
}
private static Supplier<MediaSource> buildConcatenatingMediaSource(
long placeholderDurationMs, Supplier<MediaSource>... sources) {
return () -> {
ConcatenatingMediaSource2.Builder builder = new ConcatenatingMediaSource2.Builder();
builder.setMediaItem(new MediaItem.Builder().setMediaId(TEST_MEDIA_ITEM_ID).build());
for (Supplier<MediaSource> source : sources) {
builder.add(source.get(), placeholderDurationMs);
}
return builder.build();
};
}
private static Supplier<MediaSource> buildMediaSource(
FakeTimeline.TimelineWindowDefinition... windows) {
return buildMediaSource(/* preparationDelayCount= */ 0, windows);
}
private static Supplier<MediaSource> buildMediaSource(
int preparationDelayCount, FakeTimeline.TimelineWindowDefinition... windows) {
return buildMediaSource(preparationDelayCount, /* manifests= */ null, windows);
}
private static Supplier<MediaSource> buildMediaSource(
Object[] manifests, FakeTimeline.TimelineWindowDefinition... windows) {
return buildMediaSource(/* preparationDelayCount= */ 0, manifests, windows);
}
private static Supplier<MediaSource> 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<MediaSource> mediaSourceSupplier;
public final ImmutableList<ExpectedTimelineData> expectedTimelineData;
private final int expectedAdDiscontinuities;
private final String tag;
public TestConfig(
String tag,
Supplier<MediaSource> 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;
}
}
}