diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java b/library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java
similarity index 98%
rename from library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java
rename to library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java
index 29ef1faa80..73bb49ed40 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/AbstractConcatenatedTimeline.java
@@ -13,16 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package com.google.android.exoplayer2.source;
+package com.google.android.exoplayer2;
import android.util.Pair;
-import com.google.android.exoplayer2.C;
-import com.google.android.exoplayer2.Player;
-import com.google.android.exoplayer2.Timeline;
+import com.google.android.exoplayer2.source.ShuffleOrder;
import com.google.android.exoplayer2.util.Assertions;
/** Abstract base class for the concatenation of one or more {@link Timeline}s. */
-/* package */ abstract class AbstractConcatenatedTimeline extends Timeline {
+public abstract class AbstractConcatenatedTimeline extends Timeline {
private final int childCount;
private final ShuffleOrder shuffleOrder;
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java
new file mode 100644
index 0000000000..351c9d5780
--- /dev/null
+++ b/library/core/src/main/java/com/google/android/exoplayer2/Playlist.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2;
+
+import android.os.Handler;
+import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.analytics.AnalyticsCollector;
+import com.google.android.exoplayer2.source.MaskingMediaPeriod;
+import com.google.android.exoplayer2.source.MaskingMediaSource;
+import com.google.android.exoplayer2.source.MediaPeriod;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.MediaSourceEventListener;
+import com.google.android.exoplayer2.source.ShuffleOrder;
+import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder;
+import com.google.android.exoplayer2.upstream.Allocator;
+import com.google.android.exoplayer2.upstream.TransferListener;
+import com.google.android.exoplayer2.util.Assertions;
+import com.google.android.exoplayer2.util.Util;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified
+ * during playback. It is valid for the same {@link MediaSource} instance to be present more than
+ * once in the playlist.
+ *
+ *
With the exception of the constructor, all methods are called on the playback thread.
+ */
+/* package */ class Playlist {
+
+ /** Listener for source events. */
+ public interface PlaylistInfoRefreshListener {
+
+ /**
+ * Called when the timeline of a media item has changed and a new timeline that reflects the
+ * current playlist state needs to be created by calling {@link #createTimeline()}.
+ *
+ *
Called on the playback thread.
+ */
+ void onPlaylistUpdateRequested();
+ }
+
+ private final List mediaSourceHolders;
+ private final Map mediaSourceByMediaPeriod;
+ private final Map mediaSourceByUid;
+ private final PlaylistInfoRefreshListener playlistInfoListener;
+ private final MediaSourceEventListener.EventDispatcher eventDispatcher;
+ private final HashMap childSources;
+ private final Set enabledMediaSourceHolders;
+
+ private ShuffleOrder shuffleOrder;
+ private boolean isPrepared;
+
+ @Nullable private TransferListener mediaTransferListener;
+
+ @SuppressWarnings("initialization")
+ public Playlist(PlaylistInfoRefreshListener listener) {
+ playlistInfoListener = listener;
+ shuffleOrder = new DefaultShuffleOrder(0);
+ mediaSourceByMediaPeriod = new IdentityHashMap<>();
+ mediaSourceByUid = new HashMap<>();
+ mediaSourceHolders = new ArrayList<>();
+ eventDispatcher = new MediaSourceEventListener.EventDispatcher();
+ childSources = new HashMap<>();
+ enabledMediaSourceHolders = new HashSet<>();
+ }
+
+ /**
+ * Sets the media sources replacing any sources previously contained in the playlist.
+ *
+ * @param holders The list of {@link MediaSourceHolder}s to set.
+ * @param shuffleOrder The new shuffle order.
+ * @return The new {@link Timeline}.
+ */
+ public final Timeline setMediaSources(
+ List holders, ShuffleOrder shuffleOrder) {
+ removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size());
+ return addMediaSources(/* index= */ this.mediaSourceHolders.size(), holders, shuffleOrder);
+ }
+
+ /**
+ * Adds multiple {@link MediaSourceHolder}s to the playlist.
+ *
+ * @param index The index at which the new {@link MediaSourceHolder}s will be inserted. This index
+ * must be in the range of 0 <= index <= {@link #getSize()}.
+ * @param holders A list of {@link MediaSourceHolder}s to be added.
+ * @param shuffleOrder The new shuffle order.
+ * @return The new {@link Timeline}.
+ */
+ public final Timeline addMediaSources(
+ int index, List holders, ShuffleOrder shuffleOrder) {
+ if (!holders.isEmpty()) {
+ this.shuffleOrder = shuffleOrder;
+ for (int insertionIndex = index; insertionIndex < index + holders.size(); insertionIndex++) {
+ MediaSourceHolder holder = holders.get(insertionIndex - index);
+ if (insertionIndex > 0) {
+ MediaSourceHolder previousHolder = mediaSourceHolders.get(insertionIndex - 1);
+ Timeline previousTimeline = previousHolder.mediaSource.getTimeline();
+ holder.reset(
+ /* firstWindowInChildIndex= */ previousHolder.firstWindowIndexInChild
+ + previousTimeline.getWindowCount());
+ } else {
+ holder.reset(/* firstWindowIndexInChild= */ 0);
+ }
+ Timeline newTimeline = holder.mediaSource.getTimeline();
+ correctOffsets(
+ /* startIndex= */ insertionIndex,
+ /* windowOffsetUpdate= */ newTimeline.getWindowCount());
+ mediaSourceHolders.add(insertionIndex, holder);
+ mediaSourceByUid.put(holder.uid, holder);
+ if (isPrepared) {
+ prepareChildSource(holder);
+ if (mediaSourceByMediaPeriod.isEmpty()) {
+ enabledMediaSourceHolders.add(holder);
+ } else {
+ disableChildSource(holder);
+ }
+ }
+ }
+ }
+ return createTimeline();
+ }
+
+ /**
+ * Removes a range of {@link MediaSourceHolder}s from the playlist, by specifying an initial index
+ * (included) and a final index (excluded).
+ *
+ * Note: when specified range is empty, no actual media source is removed and no exception is
+ * thrown.
+ *
+ * @param fromIndex The initial range index, pointing to the first media source that will be
+ * removed. This index must be in the range of 0 <= index <= {@link #getSize()}.
+ * @param toIndex The final range index, pointing to the first media source that will be left
+ * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}.
+ * @param shuffleOrder The new shuffle order.
+ * @return The new {@link Timeline}.
+ * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0,
+ * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex}
+ */
+ public final Timeline removeMediaSourceRange(
+ int fromIndex, int toIndex, ShuffleOrder shuffleOrder) {
+ Assertions.checkArgument(fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize());
+ this.shuffleOrder = shuffleOrder;
+ removeMediaSourcesInternal(fromIndex, toIndex);
+ return createTimeline();
+ }
+
+ /**
+ * Moves an existing media source within the playlist.
+ *
+ * @param currentIndex The current index of the media source in the playlist. This index must be
+ * in the range of 0 <= index < {@link #getSize()}.
+ * @param newIndex The target index of the media source in the playlist. This index must be in the
+ * range of 0 <= index < {@link #getSize()}.
+ * @param shuffleOrder The new shuffle order.
+ * @return The new {@link Timeline}.
+ * @throws IllegalArgumentException When an index is invalid, i.e. {@code currentIndex} < 0,
+ * {@code currentIndex} >= {@link #getSize()}, {@code newIndex} < 0
+ */
+ public final Timeline moveMediaSource(int currentIndex, int newIndex, ShuffleOrder shuffleOrder) {
+ return moveMediaSourceRange(currentIndex, currentIndex + 1, newIndex, shuffleOrder);
+ }
+
+ /**
+ * Moves a range of media sources within the playlist.
+ *
+ *
Note: when specified range is empty or the from index equals the new from index, no actual
+ * media source is moved and no exception is thrown.
+ *
+ * @param fromIndex The initial range index, pointing to the first media source of the range that
+ * will be moved. This index must be in the range of 0 <= index <= {@link #getSize()}.
+ * @param toIndex The final range index, pointing to the first media source that will be left
+ * untouched. This index must be larger or equals than {@code fromIndex}.
+ * @param newFromIndex The target index of the first media source of the range that will be moved.
+ * @param shuffleOrder The new shuffle order.
+ * @return The new {@link Timeline}.
+ * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0,
+ * {@code toIndex} < {@code fromIndex}, {@code fromIndex} > {@code toIndex}, {@code
+ * newFromIndex} < 0
+ */
+ public Timeline moveMediaSourceRange(
+ int fromIndex, int toIndex, int newFromIndex, ShuffleOrder shuffleOrder) {
+ Assertions.checkArgument(
+ fromIndex >= 0 && fromIndex <= toIndex && toIndex <= getSize() && newFromIndex >= 0);
+ this.shuffleOrder = shuffleOrder;
+ if (fromIndex == toIndex || fromIndex == newFromIndex) {
+ return createTimeline();
+ }
+ int startIndex = Math.min(fromIndex, newFromIndex);
+ int newEndIndex = newFromIndex + (toIndex - fromIndex) - 1;
+ int endIndex = Math.max(newEndIndex, toIndex - 1);
+ int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild;
+ moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex);
+ for (int i = startIndex; i <= endIndex; i++) {
+ MediaSourceHolder holder = mediaSourceHolders.get(i);
+ holder.firstWindowIndexInChild = windowOffset;
+ windowOffset += holder.mediaSource.getTimeline().getWindowCount();
+ }
+ return createTimeline();
+ }
+
+ /** Clears the playlist. */
+ public final Timeline clear(@Nullable ShuffleOrder shuffleOrder) {
+ this.shuffleOrder = shuffleOrder != null ? shuffleOrder : this.shuffleOrder.cloneAndClear();
+ removeMediaSourcesInternal(/* fromIndex= */ 0, /* toIndex= */ getSize());
+ return createTimeline();
+ }
+
+ /** Whether the playlist is prepared. */
+ public final boolean isPrepared() {
+ return isPrepared;
+ }
+
+ /** Returns the number of media sources in the playlist. */
+ public final int getSize() {
+ return mediaSourceHolders.size();
+ }
+
+ /**
+ * Sets the {@link AnalyticsCollector}.
+ *
+ * @param handler The handler on which to call the collector.
+ * @param analyticsCollector The analytics collector.
+ */
+ public final void setAnalyticsCollector(Handler handler, AnalyticsCollector analyticsCollector) {
+ eventDispatcher.addEventListener(handler, analyticsCollector);
+ }
+
+ /**
+ * Sets a new shuffle order to use when shuffling the child media sources.
+ *
+ * @param shuffleOrder A {@link ShuffleOrder}.
+ */
+ public final Timeline setShuffleOrder(ShuffleOrder shuffleOrder) {
+ int size = getSize();
+ if (shuffleOrder.getLength() != size) {
+ shuffleOrder =
+ shuffleOrder
+ .cloneAndClear()
+ .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size);
+ }
+ this.shuffleOrder = shuffleOrder;
+ return createTimeline();
+ }
+
+ /** Prepares the playlist. */
+ public final void prepare(@Nullable TransferListener mediaTransferListener) {
+ Assertions.checkState(!isPrepared);
+ this.mediaTransferListener = mediaTransferListener;
+ for (int i = 0; i < mediaSourceHolders.size(); i++) {
+ MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i);
+ prepareChildSource(mediaSourceHolder);
+ enabledMediaSourceHolders.add(mediaSourceHolder);
+ }
+ isPrepared = true;
+ }
+
+ /**
+ * Returns a new {@link MediaPeriod} identified by {@code periodId}.
+ *
+ * @param id The identifier of the period.
+ * @param allocator An {@link Allocator} from which to obtain media buffer allocations.
+ * @param startPositionUs The expected start position, in microseconds.
+ * @return A new {@link MediaPeriod}.
+ */
+ public MediaPeriod createPeriod(
+ MediaSource.MediaPeriodId id, Allocator allocator, long startPositionUs) {
+ Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid);
+ MediaSource.MediaPeriodId childMediaPeriodId =
+ id.copyWithPeriodUid(getChildPeriodUid(id.periodUid));
+ MediaSourceHolder holder = Assertions.checkNotNull(mediaSourceByUid.get(mediaSourceHolderUid));
+ enableMediaSource(holder);
+ holder.activeMediaPeriodIds.add(childMediaPeriodId);
+ MediaPeriod mediaPeriod =
+ holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs);
+ mediaSourceByMediaPeriod.put(mediaPeriod, holder);
+ disableUnusedMediaSources();
+ return mediaPeriod;
+ }
+
+ /**
+ * Releases the period.
+ *
+ * @param mediaPeriod The period to release.
+ */
+ public final void releasePeriod(MediaPeriod mediaPeriod) {
+ MediaSourceHolder holder =
+ Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod));
+ holder.mediaSource.releasePeriod(mediaPeriod);
+ holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id);
+ if (!mediaSourceByMediaPeriod.isEmpty()) {
+ disableUnusedMediaSources();
+ }
+ maybeReleaseChildSource(holder);
+ }
+
+ /** Releases the playlist. */
+ public final void release() {
+ for (MediaSourceAndListener childSource : childSources.values()) {
+ childSource.mediaSource.releaseSource(childSource.caller);
+ childSource.mediaSource.removeEventListener(childSource.eventListener);
+ }
+ childSources.clear();
+ enabledMediaSourceHolders.clear();
+ isPrepared = false;
+ }
+
+ /** Throws any pending error encountered while loading or refreshing. */
+ public final void maybeThrowSourceInfoRefreshError() throws IOException {
+ for (MediaSourceAndListener childSource : childSources.values()) {
+ childSource.mediaSource.maybeThrowSourceInfoRefreshError();
+ }
+ }
+
+ /** Creates a timeline reflecting the current state of the playlist. */
+ public final Timeline createTimeline() {
+ if (mediaSourceHolders.isEmpty()) {
+ return Timeline.EMPTY;
+ }
+ int windowOffset = 0;
+ for (int i = 0; i < mediaSourceHolders.size(); i++) {
+ MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i);
+ mediaSourceHolder.firstWindowIndexInChild = windowOffset;
+ windowOffset += mediaSourceHolder.mediaSource.getTimeline().getWindowCount();
+ }
+ return new PlaylistTimeline(mediaSourceHolders, shuffleOrder);
+ }
+
+ // Internal methods.
+
+ private void enableMediaSource(MediaSourceHolder mediaSourceHolder) {
+ enabledMediaSourceHolders.add(mediaSourceHolder);
+ @Nullable MediaSourceAndListener enabledChild = childSources.get(mediaSourceHolder);
+ if (enabledChild != null) {
+ enabledChild.mediaSource.enable(enabledChild.caller);
+ }
+ }
+
+ private void disableUnusedMediaSources() {
+ Iterator iterator = enabledMediaSourceHolders.iterator();
+ while (iterator.hasNext()) {
+ MediaSourceHolder holder = iterator.next();
+ if (holder.activeMediaPeriodIds.isEmpty()) {
+ disableChildSource(holder);
+ iterator.remove();
+ }
+ }
+ }
+
+ private void disableChildSource(MediaSourceHolder holder) {
+ @Nullable MediaSourceAndListener disabledChild = childSources.get(holder);
+ if (disabledChild != null) {
+ disabledChild.mediaSource.disable(disabledChild.caller);
+ }
+ }
+
+ private void removeMediaSourcesInternal(int fromIndex, int toIndex) {
+ for (int index = toIndex - 1; index >= fromIndex; index--) {
+ MediaSourceHolder holder = mediaSourceHolders.remove(index);
+ mediaSourceByUid.remove(holder.uid);
+ Timeline oldTimeline = holder.mediaSource.getTimeline();
+ correctOffsets(
+ /* startIndex= */ index, /* windowOffsetUpdate= */ -oldTimeline.getWindowCount());
+ holder.isRemoved = true;
+ if (isPrepared) {
+ maybeReleaseChildSource(holder);
+ }
+ }
+ }
+
+ private void correctOffsets(int startIndex, int windowOffsetUpdate) {
+ for (int i = startIndex; i < mediaSourceHolders.size(); i++) {
+ MediaSourceHolder mediaSourceHolder = mediaSourceHolders.get(i);
+ mediaSourceHolder.firstWindowIndexInChild += windowOffsetUpdate;
+ }
+ }
+
+ // Internal methods to manage child sources.
+
+ @Nullable
+ private static MediaSource.MediaPeriodId getMediaPeriodIdForChildMediaPeriodId(
+ MediaSourceHolder mediaSourceHolder, MediaSource.MediaPeriodId mediaPeriodId) {
+ for (int i = 0; i < mediaSourceHolder.activeMediaPeriodIds.size(); i++) {
+ // Ensure the reported media period id has the same window sequence number as the one created
+ // by this media source. Otherwise it does not belong to this child source.
+ if (mediaSourceHolder.activeMediaPeriodIds.get(i).windowSequenceNumber
+ == mediaPeriodId.windowSequenceNumber) {
+ Object periodUid = getPeriodUid(mediaSourceHolder, mediaPeriodId.periodUid);
+ return mediaPeriodId.copyWithPeriodUid(periodUid);
+ }
+ }
+ return null;
+ }
+
+ private static int getWindowIndexForChildWindowIndex(
+ MediaSourceHolder mediaSourceHolder, int windowIndex) {
+ return windowIndex + mediaSourceHolder.firstWindowIndexInChild;
+ }
+
+ private void prepareChildSource(MediaSourceHolder holder) {
+ MediaSource mediaSource = holder.mediaSource;
+ MediaSource.MediaSourceCaller caller =
+ (source, timeline) -> playlistInfoListener.onPlaylistUpdateRequested();
+ MediaSourceEventListener eventListener = new ForwardingEventListener(holder);
+ childSources.put(holder, new MediaSourceAndListener(mediaSource, caller, eventListener));
+ mediaSource.addEventListener(new Handler(), eventListener);
+ mediaSource.prepareSource(caller, mediaTransferListener);
+ }
+
+ private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) {
+ // Release if the source has been removed from the playlist and no periods are still active.
+ if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) {
+ MediaSourceAndListener removedChild =
+ Assertions.checkNotNull(childSources.remove(mediaSourceHolder));
+ removedChild.mediaSource.releaseSource(removedChild.caller);
+ removedChild.mediaSource.removeEventListener(removedChild.eventListener);
+ enabledMediaSourceHolders.remove(mediaSourceHolder);
+ }
+ }
+
+ /** Return uid of media source holder from period uid of concatenated source. */
+ private static Object getMediaSourceHolderUid(Object periodUid) {
+ return PlaylistTimeline.getChildTimelineUidFromConcatenatedUid(periodUid);
+ }
+
+ /** Return uid of child period from period uid of concatenated source. */
+ private static Object getChildPeriodUid(Object periodUid) {
+ return PlaylistTimeline.getChildPeriodUidFromConcatenatedUid(periodUid);
+ }
+
+ private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodUid) {
+ return PlaylistTimeline.getConcatenatedUid(holder.uid, childPeriodUid);
+ }
+
+ /* package */ static void moveMediaSourceHolders(
+ List mediaSourceHolders, int fromIndex, int toIndex, int newFromIndex) {
+ MediaSourceHolder[] removedItems = new MediaSourceHolder[toIndex - fromIndex];
+ for (int i = removedItems.length - 1; i >= 0; i--) {
+ removedItems[i] = mediaSourceHolders.remove(fromIndex + i);
+ }
+ mediaSourceHolders.addAll(
+ Math.min(newFromIndex, mediaSourceHolders.size()), Arrays.asList(removedItems));
+ }
+
+ /** Data class to hold playlist media sources together with meta data needed to process them. */
+ /* package */ static final class MediaSourceHolder {
+
+ public final MaskingMediaSource mediaSource;
+ public final Object uid;
+ public final List activeMediaPeriodIds;
+
+ public int firstWindowIndexInChild;
+ public boolean isRemoved;
+
+ public MediaSourceHolder(MediaSource mediaSource, boolean useLazyPreparation) {
+ this.mediaSource = new MaskingMediaSource(mediaSource, useLazyPreparation);
+ this.activeMediaPeriodIds = new ArrayList<>();
+ this.uid = new Object();
+ }
+
+ public void reset(int firstWindowIndexInChild) {
+ this.firstWindowIndexInChild = firstWindowIndexInChild;
+ this.isRemoved = false;
+ this.activeMediaPeriodIds.clear();
+ }
+ }
+
+ /** Timeline exposing concatenated timelines of playlist media sources. */
+ /* package */ static final class PlaylistTimeline extends AbstractConcatenatedTimeline {
+
+ private final int windowCount;
+ private final int periodCount;
+ private final int[] firstPeriodInChildIndices;
+ private final int[] firstWindowInChildIndices;
+ private final Timeline[] timelines;
+ private final Object[] uids;
+ private final HashMap childIndexByUid;
+
+ public PlaylistTimeline(
+ Collection mediaSourceHolders, ShuffleOrder shuffleOrder) {
+ super(/* isAtomic= */ false, shuffleOrder);
+ int childCount = mediaSourceHolders.size();
+ firstPeriodInChildIndices = new int[childCount];
+ firstWindowInChildIndices = new int[childCount];
+ timelines = new Timeline[childCount];
+ uids = new Object[childCount];
+ childIndexByUid = new HashMap<>();
+ int index = 0;
+ int windowCount = 0;
+ int periodCount = 0;
+ for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) {
+ timelines[index] = mediaSourceHolder.mediaSource.getTimeline();
+ firstWindowInChildIndices[index] = windowCount;
+ firstPeriodInChildIndices[index] = periodCount;
+ windowCount += timelines[index].getWindowCount();
+ periodCount += timelines[index].getPeriodCount();
+ uids[index] = mediaSourceHolder.uid;
+ childIndexByUid.put(uids[index], index++);
+ }
+ this.windowCount = windowCount;
+ this.periodCount = periodCount;
+ }
+
+ @Override
+ protected int getChildIndexByPeriodIndex(int periodIndex) {
+ return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false);
+ }
+
+ @Override
+ protected int getChildIndexByWindowIndex(int windowIndex) {
+ return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false);
+ }
+
+ @Override
+ protected int getChildIndexByChildUid(Object childUid) {
+ Integer index = childIndexByUid.get(childUid);
+ return index == null ? C.INDEX_UNSET : index;
+ }
+
+ @Override
+ protected Timeline getTimelineByChildIndex(int childIndex) {
+ return timelines[childIndex];
+ }
+
+ @Override
+ protected int getFirstPeriodIndexByChildIndex(int childIndex) {
+ return firstPeriodInChildIndices[childIndex];
+ }
+
+ @Override
+ protected int getFirstWindowIndexByChildIndex(int childIndex) {
+ return firstWindowInChildIndices[childIndex];
+ }
+
+ @Override
+ protected Object getChildUidByChildIndex(int childIndex) {
+ return uids[childIndex];
+ }
+
+ @Override
+ public int getWindowCount() {
+ return windowCount;
+ }
+
+ @Override
+ public int getPeriodCount() {
+ return periodCount;
+ }
+ }
+
+ private static final class MediaSourceAndListener {
+
+ public final MediaSource mediaSource;
+ public final MediaSource.MediaSourceCaller caller;
+ public final MediaSourceEventListener eventListener;
+
+ public MediaSourceAndListener(
+ MediaSource mediaSource,
+ MediaSource.MediaSourceCaller caller,
+ MediaSourceEventListener eventListener) {
+ this.mediaSource = mediaSource;
+ this.caller = caller;
+ this.eventListener = eventListener;
+ }
+ }
+
+ private final class ForwardingEventListener implements MediaSourceEventListener {
+
+ private final Playlist.MediaSourceHolder id;
+ private EventDispatcher eventDispatcher;
+
+ public ForwardingEventListener(Playlist.MediaSourceHolder id) {
+ eventDispatcher = Playlist.this.eventDispatcher;
+ this.id = id;
+ }
+
+ @Override
+ public void onMediaPeriodCreated(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.mediaPeriodCreated();
+ }
+ }
+
+ @Override
+ public void onMediaPeriodReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.mediaPeriodReleased();
+ }
+ }
+
+ @Override
+ public void onLoadStarted(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventData,
+ MediaLoadData mediaLoadData) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.loadStarted(loadEventData, mediaLoadData);
+ }
+ }
+
+ @Override
+ public void onLoadCompleted(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventData,
+ MediaLoadData mediaLoadData) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.loadCompleted(loadEventData, mediaLoadData);
+ }
+ }
+
+ @Override
+ public void onLoadCanceled(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventData,
+ MediaLoadData mediaLoadData) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.loadCanceled(loadEventData, mediaLoadData);
+ }
+ }
+
+ @Override
+ public void onLoadError(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ LoadEventInfo loadEventData,
+ MediaLoadData mediaLoadData,
+ IOException error,
+ boolean wasCanceled) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.loadError(loadEventData, mediaLoadData, error, wasCanceled);
+ }
+ }
+
+ @Override
+ public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.readingStarted();
+ }
+ }
+
+ @Override
+ public void onUpstreamDiscarded(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ MediaLoadData mediaLoadData) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.upstreamDiscarded(mediaLoadData);
+ }
+ }
+
+ @Override
+ public void onDownstreamFormatChanged(
+ int windowIndex,
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId,
+ MediaLoadData mediaLoadData) {
+ if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) {
+ eventDispatcher.downstreamFormatChanged(mediaLoadData);
+ }
+ }
+
+ /** Updates the event dispatcher and returns whether the event should be dispatched. */
+ private boolean maybeUpdateEventDispatcher(
+ int childWindowIndex, @Nullable MediaSource.MediaPeriodId childMediaPeriodId) {
+ @Nullable MediaSource.MediaPeriodId mediaPeriodId = null;
+ if (childMediaPeriodId != null) {
+ mediaPeriodId = getMediaPeriodIdForChildMediaPeriodId(id, childMediaPeriodId);
+ if (mediaPeriodId == null) {
+ // Media period not found. Ignore event.
+ return false;
+ }
+ }
+ int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex);
+ if (eventDispatcher.windowIndex != windowIndex
+ || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) {
+ eventDispatcher =
+ Playlist.this.eventDispatcher.withParameters(
+ windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0L);
+ }
+ return true;
+ }
+ }
+}
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java
index 545b8f5155..c1ab78a9bc 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java
@@ -19,6 +19,7 @@ import android.os.Handler;
import android.os.Message;
import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.AbstractConcatenatedTimeline;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder;
diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java
index cedc6f911d..8769a84d95 100644
--- a/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java
+++ b/library/core/src/main/java/com/google/android/exoplayer2/source/LoopingMediaSource.java
@@ -16,6 +16,7 @@
package com.google.android.exoplayer2.source;
import androidx.annotation.Nullable;
+import com.google.android.exoplayer2.AbstractConcatenatedTimeline;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.Player;
diff --git a/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java b/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java
new file mode 100644
index 0000000000..cc551db8ac
--- /dev/null
+++ b/library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java
@@ -0,0 +1,510 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.google.android.exoplayer2;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.android.exoplayer2.source.MediaSource;
+import com.google.android.exoplayer2.source.ShuffleOrder;
+import com.google.android.exoplayer2.testutil.FakeMediaSource;
+import com.google.android.exoplayer2.testutil.FakeShuffleOrder;
+import com.google.android.exoplayer2.testutil.FakeTimeline;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit test for {@link Playlist}. */
+@RunWith(AndroidJUnit4.class)
+public class PlaylistTest {
+
+ private static final int PLAYLIST_SIZE = 4;
+
+ private Playlist playlist;
+
+ @Before
+ public void setUp() {
+ playlist = new Playlist(mock(Playlist.PlaylistInfoRefreshListener.class));
+ }
+
+ @Test
+ public void testEmptyPlaylist_expectConstantTimelineInstanceEMPTY() {
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0);
+ List fakeHolders = createFakeHolders();
+
+ Timeline timeline = playlist.setMediaSources(fakeHolders, shuffleOrder);
+ assertNotSame(timeline, Timeline.EMPTY);
+
+ // Remove all media sources.
+ timeline =
+ playlist.removeMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ timeline.getWindowCount(), shuffleOrder);
+ assertSame(timeline, Timeline.EMPTY);
+
+ timeline = playlist.setMediaSources(fakeHolders, shuffleOrder);
+ assertNotSame(timeline, Timeline.EMPTY);
+ // Clear.
+ timeline = playlist.clear(shuffleOrder);
+ assertSame(timeline, Timeline.EMPTY);
+ }
+
+ @Test
+ public void testPrepareAndReprepareAfterRelease_expectSourcePreparationAfterPlaylistPrepare() {
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ playlist.setMediaSources(
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2),
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2));
+ // Verify prepare is called once on prepare.
+ verify(mockMediaSource1, times(0))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(0))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+
+ playlist.prepare(/* mediaTransferListener= */ null);
+ assertThat(playlist.isPrepared()).isTrue();
+ // Verify prepare is called once on prepare.
+ verify(mockMediaSource1, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+
+ playlist.release();
+ playlist.prepare(/* mediaTransferListener= */ null);
+ // Verify prepare is called a second time on re-prepare.
+ verify(mockMediaSource1, times(2))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(2))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ }
+
+ @Test
+ public void testSetMediaSources_playlistUnprepared_notUsingLazyPreparation() {
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2);
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ List mediaSources =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2);
+ Timeline timeline = playlist.setMediaSources(mediaSources, shuffleOrder);
+
+ assertThat(timeline.getWindowCount()).isEqualTo(2);
+ assertThat(playlist.getSize()).isEqualTo(2);
+
+ // Assert holder offsets have been set properly
+ for (int i = 0; i < mediaSources.size(); i++) {
+ Playlist.MediaSourceHolder mediaSourceHolder = mediaSources.get(i);
+ assertThat(mediaSourceHolder.isRemoved).isFalse();
+ assertThat(mediaSourceHolder.firstWindowIndexInChild).isEqualTo(i);
+ }
+
+ // Set media items again. The second holder is re-used.
+ List moreMediaSources =
+ createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class));
+ moreMediaSources.add(mediaSources.get(1));
+ timeline = playlist.setMediaSources(moreMediaSources, shuffleOrder);
+
+ assertThat(playlist.getSize()).isEqualTo(2);
+ assertThat(timeline.getWindowCount()).isEqualTo(2);
+ for (int i = 0; i < moreMediaSources.size(); i++) {
+ Playlist.MediaSourceHolder mediaSourceHolder = moreMediaSources.get(i);
+ assertThat(mediaSourceHolder.isRemoved).isFalse();
+ assertThat(mediaSourceHolder.firstWindowIndexInChild).isEqualTo(i);
+ }
+ // Expect removed holders and sources to be removed without releasing.
+ verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSources.get(0).isRemoved).isTrue();
+ // Expect re-used holder and source not to be removed.
+ verify(mockMediaSource2, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSources.get(1).isRemoved).isFalse();
+ }
+
+ @Test
+ public void testSetMediaSources_playlistPrepared_notUsingLazyPreparation() {
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2);
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ List mediaSources =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2);
+
+ playlist.prepare(/* mediaTransferListener= */ null);
+ playlist.setMediaSources(mediaSources, shuffleOrder);
+
+ // Verify sources are prepared.
+ verify(mockMediaSource1, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+
+ // Set media items again. The second holder is re-used.
+ List moreMediaSources =
+ createFakeHoldersWithSources(/* useLazyPreparation= */ false, mock(MediaSource.class));
+ moreMediaSources.add(mediaSources.get(1));
+ playlist.setMediaSources(moreMediaSources, shuffleOrder);
+
+ // Expect removed holders and sources to be removed and released.
+ verify(mockMediaSource1, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSources.get(0).isRemoved).isTrue();
+ // Expect re-used holder and source not to be removed but released.
+ verify(mockMediaSource2, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSources.get(1).isRemoved).isFalse();
+ verify(mockMediaSource2, times(2))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ }
+
+ @Test
+ public void testAddMediaSources_playlistUnprepared_notUsingLazyPreparation_expectUnprepared() {
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ List mediaSources =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2);
+ playlist.addMediaSources(/* index= */ 0, mediaSources, new ShuffleOrder.DefaultShuffleOrder(2));
+
+ assertThat(playlist.getSize()).isEqualTo(2);
+ // Verify lazy initialization does not call prepare on sources.
+ verify(mockMediaSource1, times(0))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(0))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+
+ for (int i = 0; i < mediaSources.size(); i++) {
+ assertThat(mediaSources.get(i).firstWindowIndexInChild).isEqualTo(i);
+ assertThat(mediaSources.get(i).isRemoved).isFalse();
+ }
+
+ // Add for more sources in between.
+ List moreMediaSources = createFakeHolders();
+ playlist.addMediaSources(
+ /* index= */ 1, moreMediaSources, new ShuffleOrder.DefaultShuffleOrder(/* length= */ 3));
+
+ assertThat(mediaSources.get(0).firstWindowIndexInChild).isEqualTo(0);
+ assertThat(moreMediaSources.get(0).firstWindowIndexInChild).isEqualTo(1);
+ assertThat(moreMediaSources.get(3).firstWindowIndexInChild).isEqualTo(4);
+ assertThat(mediaSources.get(1).firstWindowIndexInChild).isEqualTo(5);
+ }
+
+ @Test
+ public void testAddMediaSources_playlistPrepared_notUsingLazyPreparation_expectPrepared() {
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ playlist.prepare(/* mediaTransferListener= */ null);
+ playlist.addMediaSources(
+ /* index= */ 0,
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2),
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 2));
+
+ // Verify prepare is called on sources when added.
+ verify(mockMediaSource1, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ verify(mockMediaSource2, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ }
+
+ @Test
+ public void testMoveMediaSources() {
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4);
+ List holders = createFakeHolders();
+ playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder);
+
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+ playlist.moveMediaSource(/* currentIndex= */ 0, /* newIndex= */ 3, shuffleOrder);
+ assertFirstWindowInChildIndices(holders, 3, 0, 1, 2);
+ playlist.moveMediaSource(/* currentIndex= */ 3, /* newIndex= */ 0, shuffleOrder);
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ 2, /* newFromIndex= */ 2, shuffleOrder);
+ assertFirstWindowInChildIndices(holders, 2, 3, 0, 1);
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 2, /* toIndex= */ 4, /* newFromIndex= */ 0, shuffleOrder);
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ 2, /* newFromIndex= */ 2, shuffleOrder);
+ assertFirstWindowInChildIndices(holders, 2, 3, 0, 1);
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 2, /* toIndex= */ 3, /* newFromIndex= */ 0, shuffleOrder);
+ assertFirstWindowInChildIndices(holders, 0, 3, 1, 2);
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 3, /* toIndex= */ 4, /* newFromIndex= */ 1, shuffleOrder);
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+
+ // No-ops.
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ 4, /* newFromIndex= */ 0, shuffleOrder);
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ 0, /* newFromIndex= */ 3, shuffleOrder);
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+ }
+
+ @Test
+ public void testRemoveMediaSources_whenUnprepared_expectNoRelease() {
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ MediaSource mockMediaSource3 = mock(MediaSource.class);
+ MediaSource mockMediaSource4 = mock(MediaSource.class);
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4);
+
+ List holders =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false,
+ mockMediaSource1,
+ mockMediaSource2,
+ mockMediaSource3,
+ mockMediaSource4);
+ playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder);
+ playlist.removeMediaSourceRange(/* fromIndex= */ 1, /* toIndex= */ 3, shuffleOrder);
+
+ assertThat(playlist.getSize()).isEqualTo(2);
+ Playlist.MediaSourceHolder removedHolder1 = holders.remove(1);
+ Playlist.MediaSourceHolder removedHolder2 = holders.remove(1);
+
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+ assertThat(removedHolder1.isRemoved).isTrue();
+ assertThat(removedHolder2.isRemoved).isTrue();
+ verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource2, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource3, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource4, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ }
+
+ @Test
+ public void testRemoveMediaSources_whenPrepared_expectRelease() {
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ MediaSource mockMediaSource3 = mock(MediaSource.class);
+ MediaSource mockMediaSource4 = mock(MediaSource.class);
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4);
+
+ List holders =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false,
+ mockMediaSource1,
+ mockMediaSource2,
+ mockMediaSource3,
+ mockMediaSource4);
+ playlist.prepare(/* mediaTransferListener */ null);
+ playlist.addMediaSources(/* index= */ 0, holders, shuffleOrder);
+ playlist.removeMediaSourceRange(/* fromIndex= */ 1, /* toIndex= */ 3, shuffleOrder);
+
+ assertThat(playlist.getSize()).isEqualTo(2);
+ holders.remove(2);
+ holders.remove(1);
+
+ assertDefaultFirstWindowInChildIndexOrder(holders);
+ verify(mockMediaSource1, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource2, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource3, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ verify(mockMediaSource4, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ }
+
+ @Test
+ public void testRelease_playlistUnprepared_expectSourcesNotReleased() {
+ MediaSource mockMediaSource = mock(MediaSource.class);
+ Playlist.MediaSourceHolder mediaSourceHolder =
+ new Playlist.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false);
+
+ playlist.setMediaSources(
+ Collections.singletonList(mediaSourceHolder),
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1));
+ verify(mockMediaSource, times(0))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ playlist.release();
+ verify(mockMediaSource, times(0)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSourceHolder.isRemoved).isFalse();
+ }
+
+ @Test
+ public void testRelease_playlistPrepared_expectSourcesReleasedNotRemoved() {
+ MediaSource mockMediaSource = mock(MediaSource.class);
+ Playlist.MediaSourceHolder mediaSourceHolder =
+ new Playlist.MediaSourceHolder(mockMediaSource, /* useLazyPreparation= */ false);
+
+ playlist.prepare(/* mediaTransferListener= */ null);
+ playlist.setMediaSources(
+ Collections.singletonList(mediaSourceHolder),
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 1));
+ verify(mockMediaSource, times(1))
+ .prepareSource(
+ any(MediaSource.MediaSourceCaller.class), /* mediaTransferListener= */ isNull());
+ playlist.release();
+ verify(mockMediaSource, times(1)).releaseSource(any(MediaSource.MediaSourceCaller.class));
+ assertThat(mediaSourceHolder.isRemoved).isFalse();
+ }
+
+ @Test
+ public void testClearPlaylist_expectSourcesReleasedAndRemoved() {
+ ShuffleOrder.DefaultShuffleOrder shuffleOrder =
+ new ShuffleOrder.DefaultShuffleOrder(/* length= */ 4);
+ MediaSource mockMediaSource1 = mock(MediaSource.class);
+ MediaSource mockMediaSource2 = mock(MediaSource.class);
+ List holders =
+ createFakeHoldersWithSources(
+ /* useLazyPreparation= */ false, mockMediaSource1, mockMediaSource2);
+ playlist.setMediaSources(holders, shuffleOrder);
+ playlist.prepare(/* mediaTransferListener= */ null);
+
+ Timeline timeline = playlist.clear(shuffleOrder);
+ assertThat(timeline.isEmpty()).isTrue();
+ assertThat(holders.get(0).isRemoved).isTrue();
+ assertThat(holders.get(1).isRemoved).isTrue();
+ verify(mockMediaSource1, times(1)).releaseSource(any());
+ verify(mockMediaSource2, times(1)).releaseSource(any());
+ }
+
+ @Test
+ public void testSetMediaSources_expectTimelineUsesCustomShuffleOrder() {
+ Timeline timeline =
+ playlist.setMediaSources(createFakeHolders(), new FakeShuffleOrder(/* length=*/ 4));
+ assertTimelineUsesFakeShuffleOrder(timeline);
+ }
+
+ @Test
+ public void testAddMediaSources_expectTimelineUsesCustomShuffleOrder() {
+ Timeline timeline =
+ playlist.addMediaSources(
+ /* index= */ 0, createFakeHolders(), new FakeShuffleOrder(PLAYLIST_SIZE));
+ assertTimelineUsesFakeShuffleOrder(timeline);
+ }
+
+ @Test
+ public void testMoveMediaSources_expectTimelineUsesCustomShuffleOrder() {
+ ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE);
+ playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder);
+ Timeline timeline =
+ playlist.moveMediaSource(
+ /* currentIndex= */ 0, /* newIndex= */ 1, new FakeShuffleOrder(PLAYLIST_SIZE));
+ assertTimelineUsesFakeShuffleOrder(timeline);
+ }
+
+ @Test
+ public void testMoveMediaSourceRange_expectTimelineUsesCustomShuffleOrder() {
+ ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE);
+ playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder);
+ Timeline timeline =
+ playlist.moveMediaSourceRange(
+ /* fromIndex= */ 0,
+ /* toIndex= */ 2,
+ /* newFromIndex= */ 2,
+ new FakeShuffleOrder(PLAYLIST_SIZE));
+ assertTimelineUsesFakeShuffleOrder(timeline);
+ }
+
+ @Test
+ public void testRemoveMediaSourceRange_expectTimelineUsesCustomShuffleOrder() {
+ ShuffleOrder shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE);
+ playlist.addMediaSources(/* index= */ 0, createFakeHolders(), shuffleOrder);
+ Timeline timeline =
+ playlist.removeMediaSourceRange(
+ /* fromIndex= */ 0, /* toIndex= */ 2, new FakeShuffleOrder(/* length= */ 2));
+ assertTimelineUsesFakeShuffleOrder(timeline);
+ }
+
+ @Test
+ public void testSetShuffleOrder_expectTimelineUsesCustomShuffleOrder() {
+ playlist.setMediaSources(
+ createFakeHolders(), new ShuffleOrder.DefaultShuffleOrder(/* length= */ PLAYLIST_SIZE));
+ assertTimelineUsesFakeShuffleOrder(
+ playlist.setShuffleOrder(new FakeShuffleOrder(PLAYLIST_SIZE)));
+ }
+
+ // Internal methods.
+
+ private static void assertTimelineUsesFakeShuffleOrder(Timeline timeline) {
+ assertThat(
+ timeline.getNextWindowIndex(
+ /* windowIndex= */ 0, Player.REPEAT_MODE_OFF, /* shuffleModeEnabled= */ true))
+ .isEqualTo(-1);
+ assertThat(
+ timeline.getPreviousWindowIndex(
+ /* windowIndex= */ timeline.getWindowCount() - 1,
+ Player.REPEAT_MODE_OFF,
+ /* shuffleModeEnabled= */ true))
+ .isEqualTo(-1);
+ }
+
+ private static void assertDefaultFirstWindowInChildIndexOrder(
+ List holders) {
+ int[] indices = new int[holders.size()];
+ for (int i = 0; i < indices.length; i++) {
+ indices[i] = i;
+ }
+ assertFirstWindowInChildIndices(holders, indices);
+ }
+
+ private static void assertFirstWindowInChildIndices(
+ List holders, int... firstWindowInChildIndices) {
+ assertThat(holders).hasSize(firstWindowInChildIndices.length);
+ for (int i = 0; i < holders.size(); i++) {
+ assertThat(holders.get(i).firstWindowIndexInChild).isEqualTo(firstWindowInChildIndices[i]);
+ }
+ }
+
+ private static List createFakeHolders() {
+ MediaSource fakeMediaSource = new FakeMediaSource(new FakeTimeline(1));
+ List holders = new ArrayList<>();
+ for (int i = 0; i < PLAYLIST_SIZE; i++) {
+ holders.add(new Playlist.MediaSourceHolder(fakeMediaSource, /* useLazyPreparation= */ true));
+ }
+ return holders;
+ }
+
+ private static List createFakeHoldersWithSources(
+ boolean useLazyPreparation, MediaSource... sources) {
+ List holders = new ArrayList<>();
+ for (MediaSource mediaSource : sources) {
+ holders.add(
+ new Playlist.MediaSourceHolder(
+ mediaSource, /* useLazyPreparation= */ useLazyPreparation));
+ }
+ return holders;
+ }
+}