From 5c2806eccabcdeb8817b1ccb20bffeb087259f42 Mon Sep 17 00:00:00 2001 From: bachinger Date: Wed, 6 Nov 2019 17:21:31 +0000 Subject: [PATCH] Playlist API: add Playlist and PlaylistTest PiperOrigin-RevId: 278875587 --- .../AbstractConcatenatedTimeline.java | 8 +- .../google/android/exoplayer2/Playlist.java | 707 ++++++++++++++++++ .../source/ConcatenatingMediaSource.java | 1 + .../exoplayer2/source/LoopingMediaSource.java | 1 + .../android/exoplayer2/PlaylistTest.java | 510 +++++++++++++ 5 files changed, 1222 insertions(+), 5 deletions(-) rename library/core/src/main/java/com/google/android/exoplayer2/{source => }/AbstractConcatenatedTimeline.java (98%) create mode 100644 library/core/src/main/java/com/google/android/exoplayer2/Playlist.java create mode 100644 library/core/src/test/java/com/google/android/exoplayer2/PlaylistTest.java 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; + } +}