diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c3202547a2..e4610387ad 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -8,6 +8,12 @@ is preloaded again. * Apply the correct corresponding `TrackSelectionResult` to the playing period in track reselection. + * Add `BasePreloadManager` which coordinates the preloading for multiple + sources based on the priorities defined by their `rankingData`. + Customization is possible by extending this class. Add + `DefaultPreloadManager` which uses `PreloadMediaSource` to preload media + samples of the sources into memory, and uses an integer `rankingData` + that indicates the index of an item on the UI. * Transformer: * Add `audioConversionProcess` and `videoConversionProcess` to `ExportResult` indicating how the respective track in the output file diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesList.java new file mode 100644 index 0000000000..729581eb6a --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesList.java @@ -0,0 +1,93 @@ +/* + * Copyright 2024 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import android.content.Context; +import androidx.media3.common.util.SystemClock; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.audio.AudioRendererEventListener; +import androidx.media3.exoplayer.video.VideoRendererEventListener; +import java.util.Arrays; + +/** The default {@link RendererCapabilitiesList} implementation. */ +@UnstableApi +public final class DefaultRendererCapabilitiesList implements RendererCapabilitiesList { + + /** Factory for {@link DefaultRendererCapabilitiesList}. */ + public static final class Factory implements RendererCapabilitiesList.Factory { + private final RenderersFactory renderersFactory; + + /** + * Creates an instance. + * + * @param context A context to create a {@link DefaultRenderersFactory} that is used as the + * default. + */ + public Factory(Context context) { + this.renderersFactory = new DefaultRenderersFactory(context); + } + + /** + * Creates an instance. + * + * @param renderersFactory The {@link RenderersFactory} to create an array of {@linkplain + * Renderer renderers} whose {@link RendererCapabilities} are represented by the {@link + * DefaultRendererCapabilitiesList}. + */ + public Factory(RenderersFactory renderersFactory) { + this.renderersFactory = renderersFactory; + } + + @Override + public DefaultRendererCapabilitiesList createRendererCapabilitiesList() { + Renderer[] renderers = + renderersFactory.createRenderers( + Util.createHandlerForCurrentLooper(), + new VideoRendererEventListener() {}, + new AudioRendererEventListener() {}, + cueGroup -> {}, + metadata -> {}); + return new DefaultRendererCapabilitiesList(renderers); + } + } + + private final Renderer[] renderers; + + private DefaultRendererCapabilitiesList(Renderer[] renderers) { + this.renderers = Arrays.copyOf(renderers, renderers.length); + for (int i = 0; i < renderers.length; i++) { + this.renderers[i].init(i, PlayerId.UNSET, SystemClock.DEFAULT); + } + } + + @Override + public RendererCapabilities[] getRendererCapabilities() { + RendererCapabilities[] rendererCapabilities = new RendererCapabilities[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + rendererCapabilities[i] = renderers[i].getCapabilities(); + } + return rendererCapabilities; + } + + @Override + public void release() { + for (Renderer renderer : renderers) { + renderer.release(); + } + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilitiesList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilitiesList.java new file mode 100644 index 0000000000..4ecbde15d2 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/RendererCapabilitiesList.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import androidx.media3.common.util.UnstableApi; + +/** A list of {@link RendererCapabilities}. */ +@UnstableApi +public interface RendererCapabilitiesList { + + /** A factory for {@link RendererCapabilitiesList} instances. */ + interface Factory { + + /** Creates a {@link RendererCapabilitiesList} instance. */ + RendererCapabilitiesList createRendererCapabilitiesList(); + } + + /** Returns an array of {@link RendererCapabilities}. */ + RendererCapabilities[] getRendererCapabilities(); + + /** Releases any resources associated with this {@link RendererCapabilitiesList}. */ + void release(); +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/BasePreloadManager.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/BasePreloadManager.java new file mode 100644 index 0000000000..10bfba34f4 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/BasePreloadManager.java @@ -0,0 +1,272 @@ +/* + * Copyright 2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source.preload; + +import static androidx.media3.common.util.Assertions.checkNotNull; + +import android.os.Handler; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.source.MediaSource; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import java.util.PriorityQueue; + +/** + * A base implementation of a preload manager, which maintains the lifecycle of {@linkplain + * MediaSource media sources}. + * + *

Methods should be called on the same thread. + */ +@UnstableApi +public abstract class BasePreloadManager { + + /** A base class of the builder of the concrete extension of {@link BasePreloadManager}. */ + protected abstract static class BuilderBase { + + protected final Comparator rankingDataComparator; + protected final TargetPreloadStatusControl targetPreloadStatusControl; + protected final MediaSource.Factory mediaSourceFactory; + + public BuilderBase( + Comparator rankingDataComparator, + TargetPreloadStatusControl targetPreloadStatusControl, + MediaSource.Factory mediaSourceFactory) { + this.rankingDataComparator = rankingDataComparator; + this.targetPreloadStatusControl = targetPreloadStatusControl; + this.mediaSourceFactory = mediaSourceFactory; + } + + public abstract BasePreloadManager build(); + } + + private final Object lock; + protected final Comparator rankingDataComparator; + private final TargetPreloadStatusControl targetPreloadStatusControl; + private final MediaSource.Factory mediaSourceFactory; + private final Map mediaItemMediaSourceHolderMap; + private final Handler startPreloadingHandler; + + @GuardedBy("lock") + private final PriorityQueue sourceHolderPriorityQueue; + + @GuardedBy("lock") + @Nullable + private TargetPreloadStatusControl.PreloadStatus targetPreloadStatusOfCurrentPreloadingSource; + + protected BasePreloadManager( + Comparator rankingDataComparator, + TargetPreloadStatusControl targetPreloadStatusControl, + MediaSource.Factory mediaSourceFactory) { + lock = new Object(); + this.rankingDataComparator = rankingDataComparator; + this.targetPreloadStatusControl = targetPreloadStatusControl; + this.mediaSourceFactory = mediaSourceFactory; + mediaItemMediaSourceHolderMap = new HashMap<>(); + startPreloadingHandler = Util.createHandlerForCurrentOrMainLooper(); + sourceHolderPriorityQueue = new PriorityQueue<>(); + } + + /** + * Gets the count of the {@linkplain MediaSource media sources} currently being managed by the + * preload manager. + * + * @return The count of the {@linkplain MediaSource media sources}. + */ + public final int getSourceCount() { + return mediaItemMediaSourceHolderMap.size(); + } + + /** + * Adds a {@link MediaItem} with its {@code rankingData} to the preload manager. + * + * @param mediaItem The {@link MediaItem} to add. + * @param rankingData The ranking data that is associated with the {@code mediaItem}. + */ + public final void add(MediaItem mediaItem, T rankingData) { + add(mediaSourceFactory.createMediaSource(mediaItem), rankingData); + } + + /** + * Adds a {@link MediaSource} with its {@code rankingData} to the preload manager. + * + * @param mediaSource The {@link MediaSource} to add. + * @param rankingData The ranking data that is associated with the {@code mediaSource}. + */ + public final void add(MediaSource mediaSource, T rankingData) { + MediaSource mediaSourceForPreloading = createMediaSourceForPreloading(mediaSource); + MediaSourceHolder mediaSourceHolder = + new MediaSourceHolder(mediaSourceForPreloading, rankingData); + mediaItemMediaSourceHolderMap.put(mediaSourceForPreloading.getMediaItem(), mediaSourceHolder); + } + + /** + * Invalidates the current preload progress, and triggers a new preload progress based on the new + * priorities of the managed {@linkplain MediaSource media sources}. + */ + public final void invalidate() { + synchronized (lock) { + sourceHolderPriorityQueue.clear(); + sourceHolderPriorityQueue.addAll(mediaItemMediaSourceHolderMap.values()); + maybeStartPreloadNextSource(); + } + } + + /** + * Returns the {@link MediaSource} for the given {@link MediaItem}. + * + * @param mediaItem The media item. + * @return The source for the given {@code mediaItem} if it is managed by the preload manager, + * null otherwise. + */ + @Nullable + public final MediaSource getMediaSource(MediaItem mediaItem) { + if (!mediaItemMediaSourceHolderMap.containsKey(mediaItem)) { + return null; + } + return mediaItemMediaSourceHolderMap.get(mediaItem).mediaSource; + } + + /** + * Removes a {@link MediaItem} from the preload manager. + * + * @param mediaItem The {@link MediaItem} to remove. + */ + public final void remove(MediaItem mediaItem) { + if (mediaItemMediaSourceHolderMap.containsKey(mediaItem)) { + MediaSource mediaSource = mediaItemMediaSourceHolderMap.get(mediaItem).mediaSource; + mediaItemMediaSourceHolderMap.remove(mediaItem); + releaseSourceInternal(mediaSource); + } + } + + /** Releases the preload manager. */ + public final void release() { + for (MediaSourceHolder sourceHolder : mediaItemMediaSourceHolderMap.values()) { + releaseSourceInternal(sourceHolder.mediaSource); + } + mediaItemMediaSourceHolderMap.clear(); + synchronized (lock) { + sourceHolderPriorityQueue.clear(); + targetPreloadStatusOfCurrentPreloadingSource = null; + } + releaseInternal(); + } + + /** Called when the given {@link MediaSource} completes to preload. */ + protected final void onPreloadCompleted(MediaSource source) { + startPreloadingHandler.post( + () -> { + synchronized (lock) { + if (sourceHolderPriorityQueue.isEmpty() + || checkNotNull(sourceHolderPriorityQueue.peek()).mediaSource != source) { + return; + } + sourceHolderPriorityQueue.poll(); + maybeStartPreloadNextSource(); + } + }); + } + + /** + * Returns the {@linkplain TargetPreloadStatusControl.PreloadStatus target preload status} of the + * given {@link MediaSource}. + */ + @Nullable + protected final TargetPreloadStatusControl.PreloadStatus getTargetPreloadStatus( + MediaSource source) { + synchronized (lock) { + if (sourceHolderPriorityQueue.isEmpty() + || checkNotNull(sourceHolderPriorityQueue.peek()).mediaSource != source) { + return null; + } + return targetPreloadStatusOfCurrentPreloadingSource; + } + } + + /** + * Returns the {@link MediaSource} that the preload manager creates for preloading based on the + * given {@link MediaSource source}. The default implementation returns the same source. + * + * @param mediaSource The source based on which the preload manager creates for preloading. + * @return The source the preload manager creates for preloading. + */ + protected MediaSource createMediaSourceForPreloading(MediaSource mediaSource) { + return mediaSource; + } + + /** Returns whether the next {@link MediaSource} should start preloading. */ + protected boolean shouldStartPreloadingNextSource() { + return true; + } + + /** + * Preloads the given {@link MediaSource}. + * + * @param mediaSource The media source to preload. + * @param startPositionsUs The expected starting position in microseconds, or {@link C#TIME_UNSET} + * to indicate the default start position. + */ + protected abstract void preloadSourceInternal(MediaSource mediaSource, long startPositionsUs); + + /** + * Releases the given {@link MediaSource}. + * + * @param mediaSource The media source to release. + */ + protected abstract void releaseSourceInternal(MediaSource mediaSource); + + /** Releases the preload manager, see {@link #release()}. */ + protected void releaseInternal() {} + + @GuardedBy("lock") + private void maybeStartPreloadNextSource() { + if (!sourceHolderPriorityQueue.isEmpty() && shouldStartPreloadingNextSource()) { + MediaSourceHolder preloadingHolder = checkNotNull(sourceHolderPriorityQueue.peek()); + this.targetPreloadStatusOfCurrentPreloadingSource = + targetPreloadStatusControl.getTargetPreloadStatus(preloadingHolder.rankingData); + preloadSourceInternal(preloadingHolder.mediaSource, preloadingHolder.startPositionUs); + } + } + + /** A holder for information for preloading a single media source. */ + private final class MediaSourceHolder implements Comparable { + + public final MediaSource mediaSource; + public final T rankingData; + public final long startPositionUs; + + public MediaSourceHolder(MediaSource mediaSource, T rankingData) { + this(mediaSource, rankingData, C.TIME_UNSET); + } + + public MediaSourceHolder(MediaSource mediaSource, T rankingData, long startPositionUs) { + this.mediaSource = mediaSource; + this.rankingData = rankingData; + this.startPositionUs = startPositionUs; + } + + @Override + public int compareTo(BasePreloadManager.MediaSourceHolder o) { + return rankingDataComparator.compare(this.rankingData, o.rankingData); + } + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManager.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManager.java new file mode 100644 index 0000000000..19b6532a49 --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManager.java @@ -0,0 +1,244 @@ +/* + * Copyright 2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source.preload; + +import static androidx.media3.common.util.Assertions.checkArgument; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; +import static java.lang.Math.abs; +import static java.lang.annotation.ElementType.TYPE_USE; + +import android.os.Looper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.Renderer; +import androidx.media3.exoplayer.RendererCapabilitiesList; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.SampleQueue; +import androidx.media3.exoplayer.trackselection.TrackSelector; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.BandwidthMeter; +import com.google.common.base.Predicate; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Comparator; + +/** + * A preload manager that preloads with the {@link PreloadMediaSource} to load the media data into + * the {@link SampleQueue}. + */ +@UnstableApi +public final class DefaultPreloadManager extends BasePreloadManager { + + /** + * An implementation of {@link TargetPreloadStatusControl.PreloadStatus} that describes the + * preload status of the {@link PreloadMediaSource}. + */ + public static class Status implements TargetPreloadStatusControl.PreloadStatus { + + /** + * Stages that for the preload status. One of {@link #STAGE_TIMELINE_REFRESHED}, {@link + * #STAGE_SOURCE_PREPARED} or {@link #STAGE_LOADED_TO_POSITION_MS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target(TYPE_USE) + @IntDef( + value = { + STAGE_TIMELINE_REFRESHED, + STAGE_SOURCE_PREPARED, + STAGE_LOADED_TO_POSITION_MS, + }) + public @interface Stage {} + + /** The {@link PreloadMediaSource} has its {@link Timeline} refreshed. */ + public static final int STAGE_TIMELINE_REFRESHED = 0; + + /** The {@link PreloadMediaSource} is prepared. */ + public static final int STAGE_SOURCE_PREPARED = 1; + + /** The {@link PreloadMediaSource} is loaded to a specific position in microseconds. */ + public static final int STAGE_LOADED_TO_POSITION_MS = 2; + + private final @Stage int stage; + private final long value; + + public Status(@Stage int stage, long value) { + this.stage = stage; + this.value = value; + } + + public Status(@Stage int stage) { + this(stage, C.TIME_UNSET); + } + + @Override + public @Stage int getStage() { + return stage; + } + + @Override + public long getValue() { + return value; + } + } + + private final RendererCapabilitiesList rendererCapabilitiesList; + private final PreloadMediaSource.Factory preloadMediaSourceFactory; + + /** + * Constructs a new instance. + * + * @param targetPreloadStatusControl The {@link TargetPreloadStatusControl}. + * @param mediaSourceFactory The {@link MediaSource.Factory}. + * @param trackSelector The {@link TrackSelector}. The instance passed should be {@link + * TrackSelector#init(TrackSelector.InvalidationListener, BandwidthMeter) initialized}. + * @param bandwidthMeter The {@link BandwidthMeter}. It should be the same bandwidth meter of the + * {@link ExoPlayer} that will play the managed {@link PreloadMediaSource}. + * @param rendererCapabilitiesListFactory The {@link RendererCapabilitiesList.Factory}. To make + * preloading work properly, it must create a {@link RendererCapabilitiesList} holding an + * {@linkplain RendererCapabilitiesList#getRendererCapabilities() array of renderer + * capabilities} that matches the {@linkplain ExoPlayer#getRendererCount() count} and the + * {@linkplain ExoPlayer#getRendererType(int) renderer types} of the array of {@linkplain + * Renderer renderers} created by the {@link RenderersFactory} used by the {@link ExoPlayer} + * that will play the managed {@link PreloadMediaSource}. + * @param allocator The {@link Allocator}. It should be the same allocator of the {@link + * ExoPlayer} that will play the managed {@link PreloadMediaSource}. + * @param preloadLooper The {@link Looper} that will be used for preloading. It should be the same + * playback looper of the {@link ExoPlayer} that will play the manager {@link + * PreloadMediaSource}. + */ + public DefaultPreloadManager( + TargetPreloadStatusControl targetPreloadStatusControl, + MediaSource.Factory mediaSourceFactory, + TrackSelector trackSelector, + BandwidthMeter bandwidthMeter, + RendererCapabilitiesList.Factory rendererCapabilitiesListFactory, + Allocator allocator, + Looper preloadLooper) { + super(new RankingDataComparator(), targetPreloadStatusControl, mediaSourceFactory); + this.rendererCapabilitiesList = + rendererCapabilitiesListFactory.createRendererCapabilitiesList(); + preloadMediaSourceFactory = + new PreloadMediaSource.Factory( + mediaSourceFactory, + new SourcePreloadControl(), + trackSelector, + bandwidthMeter, + rendererCapabilitiesList.getRendererCapabilities(), + allocator, + preloadLooper); + } + + /** + * Sets the index of the current playing media. + * + * @param currentPlayingIndex The index of current playing media. + */ + public void setCurrentPlayingIndex(int currentPlayingIndex) { + RankingDataComparator rankingDataComparator = + (RankingDataComparator) this.rankingDataComparator; + rankingDataComparator.currentPlayingIndex = currentPlayingIndex; + } + + @Override + public MediaSource createMediaSourceForPreloading(MediaSource mediaSource) { + return preloadMediaSourceFactory.createMediaSource(mediaSource); + } + + @Override + protected void preloadSourceInternal(MediaSource mediaSource, long startPositionsUs) { + checkArgument(mediaSource instanceof PreloadMediaSource); + PreloadMediaSource preloadMediaSource = (PreloadMediaSource) mediaSource; + if (preloadMediaSource.isUsedByPlayer()) { + onPreloadCompleted(preloadMediaSource); + return; + } + preloadMediaSource.preload(startPositionsUs); + } + + @Override + protected void releaseSourceInternal(MediaSource mediaSource) { + checkArgument(mediaSource instanceof PreloadMediaSource); + PreloadMediaSource preloadMediaSource = (PreloadMediaSource) mediaSource; + preloadMediaSource.releasePreloadMediaSource(); + } + + @Override + protected void releaseInternal() { + rendererCapabilitiesList.release(); + } + + private static final class RankingDataComparator implements Comparator { + + public int currentPlayingIndex; + + public RankingDataComparator() { + this.currentPlayingIndex = C.INDEX_UNSET; + } + + @Override + public int compare(Integer o1, Integer o2) { + return Integer.compare(abs(o1 - currentPlayingIndex), abs(o2 - currentPlayingIndex)); + } + } + + private final class SourcePreloadControl implements PreloadMediaSource.PreloadControl { + @Override + public boolean onTimelineRefreshed(PreloadMediaSource mediaSource) { + return continueOrCompletePreloading( + mediaSource, status -> status.getStage() > Status.STAGE_TIMELINE_REFRESHED); + } + + @Override + public boolean onPrepared(PreloadMediaSource mediaSource) { + return continueOrCompletePreloading( + mediaSource, status -> status.getStage() > Status.STAGE_SOURCE_PREPARED); + } + + @Override + public boolean onContinueLoadingRequested( + PreloadMediaSource mediaSource, long bufferedPositionUs) { + return continueOrCompletePreloading( + mediaSource, + status -> + (status.getStage() == Status.STAGE_LOADED_TO_POSITION_MS + && status.getValue() > Util.usToMs(bufferedPositionUs))); + } + + private boolean continueOrCompletePreloading( + MediaSource mediaSource, Predicate continueLoadingPredicate) { + @Nullable + TargetPreloadStatusControl.PreloadStatus targetPreloadStatus = + getTargetPreloadStatus(mediaSource); + checkState(targetPreloadStatus instanceof Status); + Status status = (Status) targetPreloadStatus; + if (continueLoadingPredicate.apply(checkNotNull(status))) { + return true; + } + onPreloadCompleted(mediaSource); + return false; + } + } +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/TargetPreloadStatusControl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/TargetPreloadStatusControl.java new file mode 100644 index 0000000000..01e441fcea --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/preload/TargetPreloadStatusControl.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source.preload; + +import androidx.media3.common.util.UnstableApi; + +/** Controls the target preload status. */ +@UnstableApi +public interface TargetPreloadStatusControl { + + /** Returns the target preload status for a source with the given {@code rankingData}. */ + PreloadStatus getTargetPreloadStatus(T rankingData); + + /** Defines the status of the preloading for a source. */ + interface PreloadStatus { + + /** The stage of the preloading. */ + int getStage(); + + /** The associated value of the preloading stage. */ + long getValue(); + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesListTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesListTest.java new file mode 100644 index 0000000000..6485f64ff5 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/DefaultRendererCapabilitiesListTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.util.SystemClock; +import androidx.media3.test.utils.FakeAudioRenderer; +import androidx.media3.test.utils.FakeRenderer; +import androidx.media3.test.utils.FakeVideoRenderer; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link DefaultRendererCapabilitiesList}. */ +@RunWith(AndroidJUnit4.class) +public class DefaultRendererCapabilitiesListTest { + + private AtomicReference> underlyingRenderersReference; + private RenderersFactory renderersFactory; + + @Before + public void setUp() { + underlyingRenderersReference = new AtomicReference<>(); + renderersFactory = + (eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput) -> { + FakeRenderer[] createdRenderers = + new FakeRenderer[] { + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler( + eventHandler.getLooper(), /* callback= */ null), + videoRendererEventListener), + new FakeAudioRenderer( + SystemClock.DEFAULT.createHandler( + eventHandler.getLooper(), /* callback= */ null), + audioRendererEventListener) + }; + underlyingRenderersReference.set(ImmutableList.copyOf(createdRenderers)); + return createdRenderers; + }; + } + + @Test + public void createRendererCapabilitiesList_underlyingRenderersInitialized() { + DefaultRendererCapabilitiesList.Factory rendererCapabilitiesFactory = + new DefaultRendererCapabilitiesList.Factory(renderersFactory); + + rendererCapabilitiesFactory.createRendererCapabilitiesList(); + + List underlyingRenderers = checkNotNull(underlyingRenderersReference.get()); + for (FakeRenderer renderer : underlyingRenderers) { + assertThat(renderer.isInitialized).isTrue(); + } + } + + @Test + public void getRendererCapabilities_returnsExpectedRendererCapabilities() { + DefaultRendererCapabilitiesList.Factory rendererCapabilitiesFactory = + new DefaultRendererCapabilitiesList.Factory(renderersFactory); + DefaultRendererCapabilitiesList rendererCapabilitiesList = + rendererCapabilitiesFactory.createRendererCapabilitiesList(); + + RendererCapabilities[] rendererCapabilities = + rendererCapabilitiesList.getRendererCapabilities(); + + List underlyingRenderers = checkNotNull(underlyingRenderersReference.get()); + assertThat(rendererCapabilities).hasLength(underlyingRenderers.size()); + for (int i = 0; i < rendererCapabilities.length; i++) { + assertThat(rendererCapabilities[i].getTrackType()) + .isEqualTo(underlyingRenderers.get(i).getTrackType()); + } + } + + @Test + public void release_underlyingRenderersReleased() { + DefaultRendererCapabilitiesList.Factory rendererCapabilitiesFactory = + new DefaultRendererCapabilitiesList.Factory(renderersFactory); + DefaultRendererCapabilitiesList rendererCapabilitiesList = + rendererCapabilitiesFactory.createRendererCapabilitiesList(); + + rendererCapabilitiesList.release(); + + List underlyingRenderers = checkNotNull(underlyingRenderersReference.get()); + for (FakeRenderer renderer : underlyingRenderers) { + assertThat(renderer.isReleased).isTrue(); + } + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManagerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManagerTest.java new file mode 100644 index 0000000000..3320ab8ac7 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/preload/DefaultPreloadManagerTest.java @@ -0,0 +1,455 @@ +/* + * Copyright 2023 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source.preload; + +import static androidx.media3.common.util.Assertions.checkNotNull; +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.util.SystemClock; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.DefaultRendererCapabilitiesList; +import androidx.media3.exoplayer.Renderer; +import androidx.media3.exoplayer.RendererCapabilitiesList; +import androidx.media3.exoplayer.RenderersFactory; +import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; +import androidx.media3.exoplayer.trackselection.TrackSelector; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.BandwidthMeter; +import androidx.media3.exoplayer.upstream.DefaultAllocator; +import androidx.media3.exoplayer.upstream.DefaultBandwidthMeter; +import androidx.media3.test.utils.FakeAudioRenderer; +import androidx.media3.test.utils.FakeMediaSource; +import androidx.media3.test.utils.FakeMediaSourceFactory; +import androidx.media3.test.utils.FakeRenderer; +import androidx.media3.test.utils.FakeVideoRenderer; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; + +/** Unit test for {@link DefaultPreloadManager}. */ +@RunWith(AndroidJUnit4.class) +public class DefaultPreloadManagerTest { + @Mock private TargetPreloadStatusControl mockTargetPreloadStatusControl; + private PlayerId playerId; + private TrackSelector trackSelector; + private Allocator allocator; + private BandwidthMeter bandwidthMeter; + private RendererCapabilitiesList.Factory rendererCapabilitiesListFactory; + + @Before + public void setUp() { + playerId = new PlayerId(); + trackSelector = new DefaultTrackSelector(ApplicationProvider.getApplicationContext()); + allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + bandwidthMeter = + new DefaultBandwidthMeter.Builder(ApplicationProvider.getApplicationContext()).build(); + RenderersFactory renderersFactory = + (handler, videoListener, audioListener, textOutput, metadataOutput) -> + new Renderer[] { + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler(handler.getLooper(), /* callback= */ null), + videoListener), + new FakeAudioRenderer( + SystemClock.DEFAULT.createHandler(handler.getLooper(), /* callback= */ null), + audioListener) + }; + rendererCapabilitiesListFactory = new DefaultRendererCapabilitiesList.Factory(renderersFactory); + trackSelector.init(/* listener= */ () -> {}, bandwidthMeter); + } + + @Test + public void addByMediaItems_getCorrectCountAndSources() { + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + mockTargetPreloadStatusControl, + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()), + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + + preloadManager.add(mediaItem1, /* rankingData= */ 1); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + + assertThat(preloadManager.getSourceCount()).isEqualTo(2); + assertThat(preloadManager.getMediaSource(mediaItem1).getMediaItem()).isEqualTo(mediaItem1); + assertThat(preloadManager.getMediaSource(mediaItem2).getMediaItem()).isEqualTo(mediaItem2); + } + + @Test + public void addByMediaSources_getCorrectCountAndSources() { + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + mockTargetPreloadStatusControl, + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()), + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + DefaultMediaSourceFactory defaultMediaSourceFactory = + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()); + MediaSource mediaSourceToAdd1 = defaultMediaSourceFactory.createMediaSource(mediaItem1); + MediaSource mediaSourceToAdd2 = defaultMediaSourceFactory.createMediaSource(mediaItem2); + + preloadManager.add(mediaSourceToAdd1, /* rankingData= */ 1); + preloadManager.add(mediaSourceToAdd2, /* rankingData= */ 2); + + assertThat(preloadManager.getSourceCount()).isEqualTo(2); + assertThat(preloadManager.getMediaSource(mediaItem1).getMediaItem()).isEqualTo(mediaItem1); + assertThat(preloadManager.getMediaSource(mediaItem2).getMediaItem()).isEqualTo(mediaItem2); + } + + @Test + public void getMediaSourceForMediaItemNotAdded() { + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + mockTargetPreloadStatusControl, + new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()), + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem mediaItem = + new MediaItem.Builder() + .setMediaId("mediaId1") + .setUri("http://exoplayer.dev/video1") + .build(); + + @Nullable MediaSource mediaSource = preloadManager.getMediaSource(mediaItem); + + assertThat(mediaSource).isNull(); + } + + @Test + public void + invalidate_withoutSettingCurrentPlayingIndex_sourcesPreloadedToTargetStatusesInOrder() { + ArrayList targetPreloadStatusControlCallReference = new ArrayList<>(); + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> { + targetPreloadStatusControlCallReference.add(rankingData); + return new DefaultPreloadManager.Status( + DefaultPreloadManager.Status.STAGE_TIMELINE_REFRESHED); + }; + FakeMediaSourceFactory fakeMediaSourceFactory = new FakeMediaSourceFactory(); + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + fakeMediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem0 = + mediaItemBuilder.setMediaId("mediaId0").setUri("http://exoplayer.dev/video0").build(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + + preloadManager.add(mediaItem0, /* rankingData= */ 0); + FakeMediaSource wrappedMediaSource0 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource0.setAllowPreparation(false); + preloadManager.add(mediaItem1, /* rankingData= */ 1); + FakeMediaSource wrappedMediaSource1 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource1.setAllowPreparation(false); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + FakeMediaSource wrappedMediaSource2 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource2.setAllowPreparation(false); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(targetPreloadStatusControlCallReference).containsExactly(0); + wrappedMediaSource0.setAllowPreparation(true); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(targetPreloadStatusControlCallReference).containsExactly(0, 1).inOrder(); + } + + @Test + public void invalidate_withSettingCurrentPlayingIndex_sourcesPreloadedToTargetStatusesInOrder() { + ArrayList targetPreloadStatusControlCallReference = new ArrayList<>(); + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> { + targetPreloadStatusControlCallReference.add(rankingData); + return new DefaultPreloadManager.Status( + DefaultPreloadManager.Status.STAGE_TIMELINE_REFRESHED); + }; + FakeMediaSourceFactory fakeMediaSourceFactory = new FakeMediaSourceFactory(); + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + fakeMediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem0 = + mediaItemBuilder.setMediaId("mediaId0").setUri("http://exoplayer.dev/video0").build(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + + preloadManager.add(mediaItem0, /* rankingData= */ 0); + FakeMediaSource wrappedMediaSource0 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource0.setAllowPreparation(false); + preloadManager.add(mediaItem1, /* rankingData= */ 1); + FakeMediaSource wrappedMediaSource1 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource1.setAllowPreparation(false); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + FakeMediaSource wrappedMediaSource2 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource2.setAllowPreparation(false); + + MediaSource.MediaSourceCaller externalCaller = (source, timeline) -> {}; + PreloadMediaSource preloadMediaSource2 = + (PreloadMediaSource) preloadManager.getMediaSource(mediaItem2); + preloadMediaSource2.prepareSource( + externalCaller, bandwidthMeter.getTransferListener(), playerId); + preloadManager.setCurrentPlayingIndex(2); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(targetPreloadStatusControlCallReference).containsExactly(2, 1); + wrappedMediaSource1.setAllowPreparation(true); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(targetPreloadStatusControlCallReference).containsExactly(2, 1, 0).inOrder(); + } + + @Test + public void invalidate_beforePreloadCompletedForLastInvalidate_preloadRespectsToLatestOrder() { + ArrayList targetPreloadStatusControlCallReference = new ArrayList<>(); + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> { + targetPreloadStatusControlCallReference.add(rankingData); + return new DefaultPreloadManager.Status( + DefaultPreloadManager.Status.STAGE_TIMELINE_REFRESHED); + }; + FakeMediaSourceFactory fakeMediaSourceFactory = new FakeMediaSourceFactory(); + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + fakeMediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem0 = + mediaItemBuilder.setMediaId("mediaId0").setUri("http://exoplayer.dev/video0").build(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + preloadManager.add(mediaItem0, /* rankingData= */ 0); + FakeMediaSource wrappedMediaSource0 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource0.setAllowPreparation(false); + preloadManager.add(mediaItem1, /* rankingData= */ 1); + FakeMediaSource wrappedMediaSource1 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource1.setAllowPreparation(false); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + FakeMediaSource wrappedMediaSource2 = fakeMediaSourceFactory.getLastCreatedSource(); + wrappedMediaSource2.setAllowPreparation(false); + + MediaSource.MediaSourceCaller externalCaller = (source, timeline) -> {}; + PreloadMediaSource preloadMediaSource0 = + (PreloadMediaSource) preloadManager.getMediaSource(mediaItem0); + preloadMediaSource0.prepareSource( + externalCaller, bandwidthMeter.getTransferListener(), playerId); + preloadManager.setCurrentPlayingIndex(0); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(targetPreloadStatusControlCallReference).containsExactly(0, 1).inOrder(); + targetPreloadStatusControlCallReference.clear(); + + preloadMediaSource0.releaseSource(externalCaller); + PreloadMediaSource preloadMediaSource2 = + (PreloadMediaSource) preloadManager.getMediaSource(mediaItem2); + preloadMediaSource2.prepareSource( + externalCaller, bandwidthMeter.getTransferListener(), playerId); + preloadManager.setCurrentPlayingIndex(2); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(targetPreloadStatusControlCallReference).containsExactly(2, 1).inOrder(); + wrappedMediaSource1.setAllowPreparation(true); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(targetPreloadStatusControlCallReference).containsExactly(2, 1, 0).inOrder(); + } + + @Test + public void removeMediaItemPreviouslyAdded_returnsCorrectCountAndNullSource_sourceReleased() { + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> + new DefaultPreloadManager.Status(DefaultPreloadManager.Status.STAGE_TIMELINE_REFRESHED); + MediaSource.Factory mockMediaSourceFactory = mock(MediaSource.Factory.class); + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + mockMediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + ArrayList internalSourceToReleaseReferenceByMediaId = new ArrayList<>(); + when(mockMediaSourceFactory.createMediaSource(any())) + .thenAnswer( + invocation -> { + MediaItem mediaItem = invocation.getArgument(0); + return new FakeMediaSource() { + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void releaseSourceInternal() { + internalSourceToReleaseReferenceByMediaId.add(mediaItem.mediaId); + super.releaseSourceInternal(); + } + }; + }); + + preloadManager.add(mediaItem1, /* rankingData= */ 1); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + preloadManager.remove(mediaItem1); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(preloadManager.getSourceCount()).isEqualTo(1); + assertThat(preloadManager.getMediaSource(mediaItem1)).isNull(); + assertThat(preloadManager.getMediaSource(mediaItem2).getMediaItem()).isEqualTo(mediaItem2); + assertThat(internalSourceToReleaseReferenceByMediaId).containsExactly("mediaId1"); + } + + @Test + public void release_returnZeroCountAndNullSources_sourcesReleased() { + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> + new DefaultPreloadManager.Status(DefaultPreloadManager.Status.STAGE_TIMELINE_REFRESHED); + MediaSource.Factory mockMediaSourceFactory = mock(MediaSource.Factory.class); + AtomicReference> underlyingRenderersReference = new AtomicReference<>(); + RenderersFactory renderersFactory = + (eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput) -> { + FakeRenderer[] createdRenderers = + new FakeRenderer[] { + new FakeVideoRenderer( + SystemClock.DEFAULT.createHandler( + eventHandler.getLooper(), /* callback= */ null), + videoRendererEventListener), + new FakeAudioRenderer( + SystemClock.DEFAULT.createHandler( + eventHandler.getLooper(), /* callback= */ null), + audioRendererEventListener) + }; + underlyingRenderersReference.set(ImmutableList.copyOf(createdRenderers)); + return createdRenderers; + }; + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + mockMediaSourceFactory, + trackSelector, + bandwidthMeter, + new DefaultRendererCapabilitiesList.Factory(renderersFactory), + allocator, + Util.getCurrentOrMainLooper()); + MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); + MediaItem mediaItem1 = + mediaItemBuilder.setMediaId("mediaId1").setUri("http://exoplayer.dev/video1").build(); + MediaItem mediaItem2 = + mediaItemBuilder.setMediaId("mediaId2").setUri("http://exoplayer.dev/video2").build(); + ArrayList internalSourceToReleaseReferenceByMediaId = new ArrayList<>(); + when(mockMediaSourceFactory.createMediaSource(any())) + .thenAnswer( + invocation -> { + MediaItem mediaItem = invocation.getArgument(0); + return new FakeMediaSource() { + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + protected void releaseSourceInternal() { + internalSourceToReleaseReferenceByMediaId.add(mediaItem.mediaId); + super.releaseSourceInternal(); + } + }; + }); + + preloadManager.add(mediaItem1, /* rankingData= */ 1); + preloadManager.add(mediaItem2, /* rankingData= */ 2); + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + preloadManager.release(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(preloadManager.getSourceCount()).isEqualTo(0); + assertThat(preloadManager.getMediaSource(mediaItem1)).isNull(); + assertThat(preloadManager.getMediaSource(mediaItem2)).isNull(); + assertThat(internalSourceToReleaseReferenceByMediaId).containsExactly("mediaId1", "mediaId2"); + List underlyingRenderers = checkNotNull(underlyingRenderersReference.get()); + for (FakeRenderer renderer : underlyingRenderers) { + assertThat(renderer.isReleased).isTrue(); + } + } +}