diff --git a/RELEASENOTES.md b/RELEASENOTES.md index fc07da972f..b8667e3cb8 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -21,6 +21,7 @@ error occurs. * `MediaCodecVideoRenderer` avoids decoding samples that are neither rendered nor used as reference by other samples. + * Add `BasePreloadManager.Listener` to propagate preload events to apps. * Transformer: * Add `SurfaceAssetLoader`, which supports queueing video data to Transformer via a `Surface`. 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 index dc1b250dd8..6657ffb15a 100644 --- 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 @@ -18,10 +18,13 @@ package androidx.media3.exoplayer.source.preload; import static androidx.media3.common.util.Assertions.checkNotNull; import android.os.Handler; +import android.os.Looper; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.MediaItem; +import androidx.media3.common.util.Clock; +import androidx.media3.common.util.ListenerSet; import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.Util; import androidx.media3.exoplayer.source.MediaSource; @@ -58,10 +61,22 @@ public abstract class BasePreloadManager { public abstract BasePreloadManager build(); } + /** Listener for events in a preload manager. */ + public interface Listener { + + /** Called when the given {@link MediaItem} has completed preloading. */ + void onCompleted(MediaItem mediaItem); + + /** Called when an {@linkplain PreloadException error} occurs. */ + void onError(PreloadException exception); + } + private final Object lock; + private final Looper looper; protected final Comparator rankingDataComparator; private final TargetPreloadStatusControl targetPreloadStatusControl; private final MediaSource.Factory mediaSourceFactory; + private final ListenerSet listeners; private final Map mediaItemMediaSourceHolderMap; private final Handler startPreloadingHandler; @@ -77,14 +92,45 @@ public abstract class BasePreloadManager { TargetPreloadStatusControl targetPreloadStatusControl, MediaSource.Factory mediaSourceFactory) { lock = new Object(); + looper = Util.getCurrentOrMainLooper(); this.rankingDataComparator = rankingDataComparator; this.targetPreloadStatusControl = targetPreloadStatusControl; this.mediaSourceFactory = mediaSourceFactory; + listeners = new ListenerSet<>(looper, Clock.DEFAULT, (listener, flags) -> {}); mediaItemMediaSourceHolderMap = new HashMap<>(); startPreloadingHandler = Util.createHandlerForCurrentOrMainLooper(); sourceHolderPriorityQueue = new PriorityQueue<>(); } + /** + * Adds a {@link Listener} to listen to the preload events. + * + *

This method can be called from any thread. + */ + public void addListener(Listener listener) { + listeners.add(listener); + } + + /** + * Removes a {@link Listener}. + * + * @throws IllegalStateException If this method is called from the wrong thread. + */ + public void removeListener(Listener listener) { + verifyApplicationThread(); + listeners.remove(listener); + } + + /** + * Clears all the {@linkplain Listener listeners}. + * + * @throws IllegalStateException If this method is called from the wrong thread. + */ + public void clearListeners() { + verifyApplicationThread(); + listeners.clear(); + } + /** * Gets the count of the {@linkplain MediaSource media sources} currently being managed by the * preload manager. @@ -206,15 +252,33 @@ public abstract class BasePreloadManager { public final void release() { reset(); releaseInternal(); + clearListeners(); } - /** Called when the given {@link MediaSource} completes to preload. */ + /** Called when the given {@link MediaSource} completes preloading. */ protected final void onPreloadCompleted(MediaSource source) { + listeners.sendEvent( + /* eventFlag= */ C.INDEX_UNSET, listener -> listener.onCompleted(source.getMediaItem())); + maybeAdvanceToNextSource(source); + } + + /** Called when an error occurs. */ + protected final void onPreloadError(PreloadException error, MediaSource source) { + listeners.sendEvent(/* eventFlag= */ C.INDEX_UNSET, listener -> listener.onError(error)); + maybeAdvanceToNextSource(source); + } + + /** Called when the given {@link MediaSource} has been skipped before completing preloading. */ + protected final void onPreloadSkipped(MediaSource source) { + maybeAdvanceToNextSource(source); + } + + private void maybeAdvanceToNextSource(MediaSource preloadingSource) { startPreloadingHandler.post( () -> { synchronized (lock) { if (sourceHolderPriorityQueue.isEmpty() - || checkNotNull(sourceHolderPriorityQueue.peek()).mediaSource != source) { + || checkNotNull(sourceHolderPriorityQueue.peek()).mediaSource != preloadingSource) { return; } do { @@ -307,6 +371,12 @@ public abstract class BasePreloadManager { return false; } + private void verifyApplicationThread() { + if (Looper.myLooper() != looper) { + throw new IllegalStateException("Preload manager is accessed on the wrong thread."); + } + } + /** A holder for information for preloading a single media source. */ private final class MediaSourceHolder implements Comparable { 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 index 43197fbb20..0dee797a79 100644 --- 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 @@ -241,17 +241,17 @@ public final class DefaultPreloadManager extends BasePreloadManager { @Override public void onUsedByPlayer(PreloadMediaSource mediaSource) { - onPreloadCompleted(mediaSource); + DefaultPreloadManager.this.onPreloadSkipped(mediaSource); } @Override public void onLoadedToTheEndOfSource(PreloadMediaSource mediaSource) { - onPreloadCompleted(mediaSource); + DefaultPreloadManager.this.onPreloadCompleted(mediaSource); } @Override public void onPreloadError(PreloadException error, PreloadMediaSource mediaSource) { - onPreloadCompleted(mediaSource); + DefaultPreloadManager.this.onPreloadError(error, mediaSource); } private boolean continueOrCompletePreloading( @@ -269,8 +269,10 @@ public final class DefaultPreloadManager extends BasePreloadManager { if (clearExceededDataFromTargetPreloadStatus) { clearSourceInternal(mediaSource); } + DefaultPreloadManager.this.onPreloadCompleted(mediaSource); + } else { + DefaultPreloadManager.this.onPreloadSkipped(mediaSource); } - onPreloadCompleted(mediaSource); return false; } } 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 index addbb0fac8..15b20fb2b0 100644 --- 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 @@ -45,6 +45,7 @@ import androidx.media3.exoplayer.RenderersFactory; import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.MediaPeriod; import androidx.media3.exoplayer.source.MediaSource; @@ -57,6 +58,7 @@ 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.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.test.utils.FakeAudioRenderer; import androidx.media3.test.utils.FakeMediaPeriod; import androidx.media3.test.utils.FakeMediaSource; @@ -67,6 +69,8 @@ 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 com.google.common.collect.Iterables; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -207,6 +211,8 @@ public class DefaultPreloadManagerTest { rendererCapabilitiesListFactory, allocator, Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); MediaItem mediaItem0 = mediaItemBuilder @@ -228,9 +234,12 @@ public class DefaultPreloadManagerTest { preloadManager.add(mediaItem2, /* rankingData= */ 2); preloadManager.invalidate(); - runMainLooperUntil(() -> targetPreloadStatusControlCallStates.size() == 3); + runMainLooperUntil(() -> preloadManagerListener.onCompletedMediaItemRecords.size() == 3); assertThat(targetPreloadStatusControlCallStates).containsExactly(0, 1, 2).inOrder(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords) + .containsExactly(mediaItem0, mediaItem1, mediaItem2) + .inOrder(); } @Test @@ -259,6 +268,8 @@ public class DefaultPreloadManagerTest { rendererCapabilitiesListFactory, allocator, Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); MediaItem mediaItem0 = mediaItemBuilder @@ -278,17 +289,16 @@ public class DefaultPreloadManagerTest { preloadManager.add(mediaItem0, /* rankingData= */ 0); preloadManager.add(mediaItem1, /* rankingData= */ 1); preloadManager.add(mediaItem2, /* rankingData= */ 2); - PreloadMediaSource preloadMediaSource2 = - (PreloadMediaSource) preloadManager.getMediaSource(mediaItem2); - preloadMediaSource2.prepareSource( - (source, timeline) -> {}, bandwidthMeter.getTransferListener(), PlayerId.UNSET); preloadManager.setCurrentPlayingIndex(2); currentPlayingItemIndex.set(2); preloadManager.invalidate(); - runMainLooperUntil(() -> targetPreloadStatusControlCallStates.size() == 3); + runMainLooperUntil(() -> preloadManagerListener.onCompletedMediaItemRecords.size() == 3); assertThat(targetPreloadStatusControlCallStates).containsExactly(2, 1, 0).inOrder(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords) + .containsExactly(mediaItem2, mediaItem1, mediaItem0) + .inOrder(); } @Test @@ -309,6 +319,8 @@ public class DefaultPreloadManagerTest { rendererCapabilitiesListFactory, allocator, Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); MediaItem mediaItem0 = mediaItemBuilder.setMediaId("mediaId0").setUri("http://exoplayer.dev/video0").build(); @@ -327,11 +339,12 @@ public class DefaultPreloadManagerTest { (PreloadMediaSource) preloadManager.getMediaSource(mediaItem0); preloadMediaSource0.prepareSource( (source, timeline) -> {}, bandwidthMeter.getTransferListener(), PlayerId.UNSET); + wrappedMediaSource0.setAllowPreparation(true); + wrappedMediaSource1.setAllowPreparation(true); shadowOf(Looper.getMainLooper()).idle(); - // The preload of mediaItem0 should complete and the preload manager continues to preload - // mediaItem1, even when the preloadMediaSource0 hasn't finished preparation. assertThat(targetPreloadStatusControlCallStates).containsExactly(0, 1).inOrder(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords).containsExactly(mediaItem1); } @Test @@ -352,6 +365,8 @@ public class DefaultPreloadManagerTest { rendererCapabilitiesListFactory, allocator, Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); MediaItem mediaItem0 = mediaItemBuilder.setMediaId("mediaId0").setUri("http://exoplayer.dev/video0").build(); @@ -368,35 +383,31 @@ public class DefaultPreloadManagerTest { 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.UNSET); preloadManager.setCurrentPlayingIndex(0); preloadManager.invalidate(); + wrappedMediaSource0.setAllowPreparation(true); shadowOf(Looper.getMainLooper()).idle(); assertThat(targetPreloadStatusControlCallStates).containsExactly(0, 1).inOrder(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords).containsExactly(mediaItem0); targetPreloadStatusControlCallStates.clear(); - preloadMediaSource0.releaseSource(externalCaller); - PreloadMediaSource preloadMediaSource2 = - (PreloadMediaSource) preloadManager.getMediaSource(mediaItem2); - preloadMediaSource2.prepareSource( - externalCaller, bandwidthMeter.getTransferListener(), PlayerId.UNSET); + preloadManagerListener.reset(); preloadManager.setCurrentPlayingIndex(2); preloadManager.invalidate(); - // Simulate the delay of the preparation of wrappedMediaSource0, which was triggered at the + // Simulate the delay of the preparation of wrappedMediaSource1, which was triggered at the // first call of invalidate(). This is expected to result in nothing, as the whole flow of // preloading should respect the priority order triggered by the latest call of invalidate(). - wrappedMediaSource0.setAllowPreparation(true); - shadowOf(Looper.getMainLooper()).idle(); - assertThat(targetPreloadStatusControlCallStates).containsExactly(2, 1).inOrder(); wrappedMediaSource1.setAllowPreparation(true); shadowOf(Looper.getMainLooper()).idle(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords).isEmpty(); + wrappedMediaSource2.setAllowPreparation(true); + shadowOf(Looper.getMainLooper()).idle(); assertThat(targetPreloadStatusControlCallStates).containsExactly(2, 1, 0).inOrder(); + assertThat(preloadManagerListener.onCompletedMediaItemRecords) + .containsExactly(mediaItem2, mediaItem1, mediaItem0) + .inOrder(); } @Test @@ -419,6 +430,8 @@ public class DefaultPreloadManagerTest { rendererCapabilitiesListFactory, allocator, Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); MediaItem.Builder mediaItemBuilder = new MediaItem.Builder(); MediaItem mediaItem0 = mediaItemBuilder @@ -443,6 +456,91 @@ public class DefaultPreloadManagerTest { shadowOf(Looper.getMainLooper()).idle(); assertThat(targetPreloadStatusControlCallStates).containsExactly(0, 1, 2); + assertThat(preloadManagerListener.onCompletedMediaItemRecords).isEmpty(); + } + + @Test + public void invalidate_sourceHasPreloadException_continuesPreloadingNextSource() { + ArrayList targetPreloadStatusControlCallStates = new ArrayList<>(); + TargetPreloadStatusControl targetPreloadStatusControl = + rankingData -> { + targetPreloadStatusControlCallStates.add(rankingData); + return new DefaultPreloadManager.Status(STAGE_SOURCE_PREPARED); + }; + IOException causeException = new IOException("Failed to refresh source info"); + 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(); + MediaSource.Factory mediaSourceFactory = + new MediaSource.Factory() { + @Override + public MediaSource.Factory setDrmSessionManagerProvider( + DrmSessionManagerProvider drmSessionManagerProvider) { + return this; + } + + @Override + public MediaSource.Factory setLoadErrorHandlingPolicy( + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + return this; + } + + @Override + public @C.ContentType int[] getSupportedTypes() { + return new int[0]; + } + + @Override + public MediaSource createMediaSource(MediaItem mediaItem) { + FakeMediaSource mediaSource = + new FakeMediaSource() { + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + }; + if (mediaItem.equals(mediaItem0)) { + mediaSource = + new FakeMediaSource() { + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw causeException; + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + }; + mediaSource.setAllowPreparation(false); + } + return mediaSource; + } + }; + DefaultPreloadManager preloadManager = + new DefaultPreloadManager( + targetPreloadStatusControl, + mediaSourceFactory, + trackSelector, + bandwidthMeter, + rendererCapabilitiesListFactory, + allocator, + Util.getCurrentOrMainLooper()); + TestPreloadManagerListener preloadManagerListener = new TestPreloadManagerListener(); + preloadManager.addListener(preloadManagerListener); + preloadManager.add(mediaItem0, /* rankingData= */ 0); + preloadManager.add(mediaItem1, /* rankingData= */ 1); + + preloadManager.invalidate(); + shadowOf(Looper.getMainLooper()).idle(); + + assertThat(targetPreloadStatusControlCallStates).containsExactly(0, 1).inOrder(); + assertThat(Iterables.getOnlyElement(preloadManagerListener.onErrorPreloadExceptionRecords)) + .hasCauseThat() + .isEqualTo(causeException); + assertThat(preloadManagerListener.onCompletedMediaItemRecords).containsExactly(mediaItem1); } @Test @@ -820,4 +918,30 @@ public class DefaultPreloadManagerTest { assertThat(renderer.isReleased).isTrue(); } } + + private static class TestPreloadManagerListener implements BasePreloadManager.Listener { + + public final List onCompletedMediaItemRecords; + public final List onErrorPreloadExceptionRecords; + + public TestPreloadManagerListener() { + onCompletedMediaItemRecords = new ArrayList<>(); + onErrorPreloadExceptionRecords = new ArrayList<>(); + } + + @Override + public void onCompleted(MediaItem mediaItem) { + onCompletedMediaItemRecords.add(mediaItem); + } + + @Override + public void onError(PreloadException exception) { + onErrorPreloadExceptionRecords.add(exception); + } + + public void reset() { + onCompletedMediaItemRecords.clear(); + onErrorPreloadExceptionRecords.clear(); + } + } }