diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 64ab7a28ba..9f018b6c60 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -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
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java
new file mode 100644
index 0000000000..7aacffc6f7
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2.java
@@ -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}.
+ *
+ *
This class can only be used under the following conditions:
+ *
+ *
+ *
All sources must be non-empty.
+ *
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.
+ *
+ */
+@UnstableApi
+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/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ConcatenatingMediaSource2Test.java
new file mode 100644
index 0000000000..14d4e94306
--- /dev/null
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/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 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 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