From 8f2161c43d85b554b809ebc72bac0342e2a87316 Mon Sep 17 00:00:00 2001 From: tofunmi Date: Tue, 10 Oct 2023 10:41:43 -0700 Subject: [PATCH] Support customized loading in ExternallyLoadedMediaSource PiperOrigin-RevId: 572299971 --- .../source/DefaultMediaSourceFactory.java | 24 ++++- .../exoplayer/source/ExternalLoader.java | 46 +++++++++ .../source/ExternallyLoadedMediaPeriod.java | 78 +++++++++++----- .../source/ExternallyLoadedMediaSource.java | 24 +++-- .../ExternallyLoadedImagePlaybackTest.java | 93 ++++++++++++++++++- .../ExternallyLoadedMediaSourceTest.java | 3 +- 6 files changed, 235 insertions(+), 33 deletions(-) create mode 100644 libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternalLoader.java diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java index 8ded3eee8b..f18de4f6bf 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/DefaultMediaSourceFactory.java @@ -116,6 +116,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { private DataSource.Factory dataSourceFactory; @Nullable private MediaSource.Factory serverSideAdInsertionMediaSourceFactory; + @Nullable private ExternalLoader externalImageLoader; @Nullable private AdsLoader.Provider adsLoaderProvider; @Nullable private AdViewProvider adViewProvider; @Nullable private LoadErrorHandlingPolicy loadErrorHandlingPolicy; @@ -315,6 +316,26 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { return this; } + /** + * Sets the {@link ExternalLoader} to be called when loading starts in {@link + * ExternallyLoadedMediaSource} when loading images in an external Image Management Framework (for + * example, Glide). + * + *

This loader is only used when the {@link MediaItem.LocalConfiguration#mimeType} is set to + * {@link MimeTypes#APPLICATION_EXTERNALLY_LOADED_IMAGE}. + * + * @param externalImageLoader The {@link ExternalLoader} to load the media or {@code null} to + * remove a previously set {@link ExternalLoader}. + * @return This factory, for convenience. + */ + @CanIgnoreReturnValue + @UnstableApi + public DefaultMediaSourceFactory setExternalImageLoader( + @Nullable ExternalLoader externalImageLoader) { + this.externalImageLoader = externalImageLoader; + return this; + } + /** * Sets the target live offset for live streams, in milliseconds. * @@ -440,7 +461,8 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { if (Objects.equals( mediaItem.localConfiguration.mimeType, MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE)) { return new ExternallyLoadedMediaSource.Factory( - msToUs(mediaItem.localConfiguration.imageDurationMs)) + msToUs(mediaItem.localConfiguration.imageDurationMs), + checkNotNull(externalImageLoader)) .createMediaSource(mediaItem); } @C.ContentType diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternalLoader.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternalLoader.java new file mode 100644 index 0000000000..cee5d7b8cb --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternalLoader.java @@ -0,0 +1,46 @@ +/* + * 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 + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.media3.exoplayer.source; + +import android.net.Uri; +import androidx.media3.common.util.UnstableApi; +import com.google.common.util.concurrent.ListenableFuture; +import org.checkerframework.checker.nullness.qual.Nullable; + +/** An object for loading media outside of ExoPlayer's loading mechanism. */ +@UnstableApi +public interface ExternalLoader { + + /** A data class providing information associated with the load event. */ + final class LoadRequest { + + /** The {@link Uri} stored in the load request object. */ + public final Uri uri; + + /** Creates an instance. */ + public LoadRequest(Uri uri) { + this.uri = uri; + } + } + + /** + * Loads the external media. + * + * @param loadRequest The load request. + * @return The {@link ListenableFuture} tracking the completion of the loading work. + */ + ListenableFuture<@Nullable ?> load(LoadRequest loadRequest); +} diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaPeriod.java index 33b03ee20a..1070d459a4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaPeriod.java @@ -16,6 +16,7 @@ package androidx.media3.exoplayer.source; import android.net.Uri; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.TrackGroup; @@ -24,8 +25,17 @@ import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.exoplayer.FormatHolder; import androidx.media3.exoplayer.LoadingInfo; import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.source.ExternalLoader.LoadRequest; import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import com.google.common.base.Charsets; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** * A {@link MediaPeriod} that puts a {@link Charsets#UTF_8}-encoded {@link Uri} into the sample @@ -33,24 +43,42 @@ import com.google.common.base.Charsets; */ /* package */ final class ExternallyLoadedMediaPeriod implements MediaPeriod { - private final Format format; + private final Uri uri; + private final ExternalLoader externalLoader; private final TrackGroupArray tracks; private final byte[] sampleData; + private final AtomicBoolean loadingFinished; + private final AtomicReference loadingThrowable; + private @MonotonicNonNull ListenableFuture loadingFuture; - // TODO: b/303375301 - Removing this variable (replacing it with static returns in the methods - // that - // use it) causes playback to hang. - private boolean loadingFinished; - - public ExternallyLoadedMediaPeriod(Uri uri, String mimeType) { - this.format = new Format.Builder().setSampleMimeType(mimeType).build(); + public ExternallyLoadedMediaPeriod(Uri uri, String mimeType, ExternalLoader externalLoader) { + this.uri = uri; + Format format = new Format.Builder().setSampleMimeType(mimeType).build(); + this.externalLoader = externalLoader; tracks = new TrackGroupArray(new TrackGroup(format)); sampleData = uri.toString().getBytes(Charsets.UTF_8); + loadingFinished = new AtomicBoolean(); + loadingThrowable = new AtomicReference<>(); } @Override public void prepare(Callback callback, long positionUs) { callback.onPrepared(this); + loadingFuture = externalLoader.load(new LoadRequest(uri)); + Futures.addCallback( + loadingFuture, + new FutureCallback<@NullableType Object>() { + @Override + public void onSuccess(@Nullable Object result) { + loadingFinished.set(true); + } + + @Override + public void onFailure(Throwable t) { + loadingThrowable.set(t); + } + }, + MoreExecutors.directExecutor()); } @Override @@ -105,26 +133,22 @@ import com.google.common.base.Charsets; @Override public long getBufferedPositionUs() { - return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + return loadingFinished.get() ? C.TIME_END_OF_SOURCE : 0; } @Override public long getNextLoadPositionUs() { - return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + return loadingFinished.get() ? C.TIME_END_OF_SOURCE : 0; } @Override public boolean continueLoading(LoadingInfo loadingInfo) { - if (loadingFinished) { - return false; - } - loadingFinished = true; - return true; + return !loadingFinished.get(); } @Override public boolean isLoading() { - return !loadingFinished; + return !loadingFinished.get(); } @Override @@ -132,8 +156,13 @@ import com.google.common.base.Charsets; // Do nothing. } - private final class SampleStreamImpl implements SampleStream { + public void releasePeriod() { + if (loadingFuture != null) { + loadingFuture.cancel(/* mayInterruptIfRunning= */ false); + } + } + private final class SampleStreamImpl implements SampleStream { private static final int STREAM_STATE_SEND_FORMAT = 0; private static final int STREAM_STATE_SEND_SAMPLE = 1; private static final int STREAM_STATE_END_OF_STREAM = 2; @@ -146,13 +175,16 @@ import com.google.common.base.Charsets; @Override public boolean isReady() { - return loadingFinished; + return loadingFinished.get(); } @Override - public void maybeThrowError() { - // Do nothing. - + public void maybeThrowError() throws IOException { + @Nullable + Throwable loadingThrowable = ExternallyLoadedMediaPeriod.this.loadingThrowable.get(); + if (loadingThrowable != null) { + throw new IOException(loadingThrowable); + } } @Override @@ -170,6 +202,10 @@ import com.google.common.base.Charsets; return C.RESULT_FORMAT_READ; } + if (!loadingFinished.get()) { + return C.RESULT_NOTHING_READ; + } + int sampleSize = sampleData.length; buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); buffer.timeUs = 0; diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSource.java index fc90b09fff..92a457f724 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSource.java @@ -43,18 +43,24 @@ import java.util.Objects; @UnstableApi public final class ExternallyLoadedMediaSource extends BaseMediaSource { + private final ExternalLoader externalLoader; + /** Factory for {@link ExternallyLoadedMediaSource}. */ public static final class Factory implements MediaSource.Factory { - private final long timelineDurationUs; + private final ExternalLoader externalLoader; /** * Creates an instance. * - * @param timelineDurationUs The duration of the {@link SinglePeriodTimeline} created. + * @param timelineDurationUs The duration of the {@link SinglePeriodTimeline} created, in + * microseconds. + * @param externalLoader The {@link ExternalLoader} to load the media in preparation for + * playback. */ - Factory(long timelineDurationUs) { + public Factory(long timelineDurationUs, ExternalLoader externalLoader) { this.timelineDurationUs = timelineDurationUs; + this.externalLoader = externalLoader; } /** Does nothing. {@link ExternallyLoadedMediaSource} does not support DRM. */ @@ -80,7 +86,7 @@ public final class ExternallyLoadedMediaSource extends BaseMediaSource { @Override public ExternallyLoadedMediaSource createMediaSource(MediaItem mediaItem) { - return new ExternallyLoadedMediaSource(mediaItem, timelineDurationUs); + return new ExternallyLoadedMediaSource(mediaItem, timelineDurationUs, externalLoader); } } @@ -89,9 +95,11 @@ public final class ExternallyLoadedMediaSource extends BaseMediaSource { @GuardedBy("this") private MediaItem mediaItem; - private ExternallyLoadedMediaSource(MediaItem mediaItem, long timelineDurationUs) { + private ExternallyLoadedMediaSource( + MediaItem mediaItem, long timelineDurationUs, ExternalLoader externalLoader) { this.mediaItem = mediaItem; this.timelineDurationUs = timelineDurationUs; + this.externalLoader = externalLoader; } @Override @@ -118,7 +126,7 @@ public final class ExternallyLoadedMediaSource extends BaseMediaSource { } @Override - public synchronized boolean canUpdateMediaItem(MediaItem mediaItem) { + public boolean canUpdateMediaItem(MediaItem mediaItem) { @Nullable MediaItem.LocalConfiguration newConfiguration = mediaItem.localConfiguration; MediaItem.LocalConfiguration oldConfiguration = checkNotNull(getMediaItem().localConfiguration); return newConfiguration != null @@ -145,11 +153,11 @@ public final class ExternallyLoadedMediaSource extends BaseMediaSource { checkNotNull( mediaItem.localConfiguration.mimeType, "Externally loaded mediaItems require a MIME type."); return new ExternallyLoadedMediaPeriod( - mediaItem.localConfiguration.uri, mediaItem.localConfiguration.mimeType); + mediaItem.localConfiguration.uri, mediaItem.localConfiguration.mimeType, externalLoader); } @Override public void releasePeriod(MediaPeriod mediaPeriod) { - // Do nothing. + ((ExternallyLoadedMediaPeriod) mediaPeriod).releasePeriod(); } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java index d1d7d7b89e..c30ea0aa45 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java @@ -29,14 +29,18 @@ import androidx.media3.common.MediaItem; import androidx.media3.common.MimeTypes; import androidx.media3.common.Player; import androidx.media3.common.util.Clock; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.datasource.AssetDataSource; import androidx.media3.datasource.DataSourceUtil; import androidx.media3.datasource.DataSpec; +import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.RendererCapabilities; import androidx.media3.exoplayer.image.BitmapFactoryImageDecoder; import androidx.media3.exoplayer.image.ImageDecoder; import androidx.media3.exoplayer.image.ImageDecoderException; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.test.utils.CapturingRenderersFactory; import androidx.media3.test.utils.DumpFileAsserts; import androidx.media3.test.utils.FakeClock; @@ -45,7 +49,11 @@ import androidx.media3.test.utils.robolectric.TestPlayerRunHelper; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.base.Charsets; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; import java.io.IOException; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.annotation.GraphicsMode; @@ -58,14 +66,26 @@ public final class ExternallyLoadedImagePlaybackTest { private static final String INPUT_FILE = "png/non-motion-photo-shortened.png"; @Test - public void test() throws Exception { + public void imagePlayback_validExternalLoader_callsLoadOnceAndPlaysSuccessfully() + throws Exception { Context applicationContext = ApplicationProvider.getApplicationContext(); CapturingRenderersFactory renderersFactory = new CapturingRenderersFactory(applicationContext, /* addImageRenderer= */ true) .setImageDecoderFactory(new CustomImageDecoderFactory()); Clock clock = new FakeClock(/* isAutoAdvancing= */ true); + AtomicInteger externalLoaderCallCount = new AtomicInteger(); + ListeningExecutorService listeningExecutorService = + MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + MediaSource.Factory mediaSourceFactory = + new DefaultMediaSourceFactory(applicationContext) + .setExternalImageLoader( + unused -> + listeningExecutorService.submit(externalLoaderCallCount::getAndIncrement)); ExoPlayer player = - new ExoPlayer.Builder(applicationContext, renderersFactory).setClock(clock).build(); + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(clock) + .setMediaSourceFactory(mediaSourceFactory) + .build(); PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); long durationMs = 5 * C.MILLIS_PER_SECOND; player.setMediaItem( @@ -83,11 +103,80 @@ public final class ExternallyLoadedImagePlaybackTest { long playbackDurationMs = clock.elapsedRealtime() - playerStartedMs; player.release(); + assertThat(externalLoaderCallCount.get()).isEqualTo(1); assertThat(playbackDurationMs).isAtLeast(durationMs); DumpFileAsserts.assertOutput( applicationContext, playbackOutput, "playbackdumps/" + INPUT_FILE + ".dump"); } + @Test + public void imagePlayback_externalLoaderFutureFails_propagatesFailure() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext, /* addImageRenderer= */ true) + .setImageDecoderFactory(new CustomImageDecoderFactory()); + ListeningExecutorService listeningExecutorService = + MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + MediaSource.Factory mediaSourceFactory = + new DefaultMediaSourceFactory(applicationContext) + .setExternalImageLoader( + unused -> + listeningExecutorService.submit( + () -> { + throw new RuntimeException("My Exception"); + })); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .setMediaSourceFactory(mediaSourceFactory) + .build(); + player.setMediaItem( + new MediaItem.Builder() + .setUri("asset:///media/" + INPUT_FILE) + .setImageDurationMs(5 * C.MILLIS_PER_SECOND) + .setMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) + .build()); + player.prepare(); + + ExoPlaybackException error = TestPlayerRunHelper.runUntilError(player); + assertThat(error).isNotNull(); + } + + @Test + public void imagePlayback_loadingCompletedWhenFutureCompletes() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext, /* addImageRenderer= */ true) + .setImageDecoderFactory(new CustomImageDecoderFactory()); + ListeningExecutorService listeningExecutorService = + MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + ConditionVariable loadingComplete = new ConditionVariable(); + MediaSource.Factory mediaSourceFactory = + new DefaultMediaSourceFactory(applicationContext) + .setExternalImageLoader( + unused -> listeningExecutorService.submit(loadingComplete::blockUninterruptible)); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .setMediaSourceFactory(mediaSourceFactory) + .build(); + player.setMediaItem( + new MediaItem.Builder() + .setUri("asset:///media/" + INPUT_FILE) + .setImageDurationMs(5 * C.MILLIS_PER_SECOND) + .setMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) + .build()); + player.prepare(); + + TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player); + + assertThat(player.isLoading()).isTrue(); + + loadingComplete.open(); + // Assert the player stops loading. + TestPlayerRunHelper.runUntilIsLoading(player, /* expectedIsLoading= */ false); + } + private static final class CustomImageDecoderFactory implements ImageDecoder.Factory { @Override diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSourceTest.java index 894a5325f4..10eff53ac1 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSourceTest.java @@ -26,6 +26,7 @@ import androidx.media3.exoplayer.analytics.PlayerId; import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.util.concurrent.SettableFuture; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; import org.junit.runner.RunWith; @@ -128,7 +129,7 @@ public class ExternallyLoadedMediaSourceTest { private static MediaSource buildMediaSource(MediaItem mediaItem) { return new ExternallyLoadedMediaSource.Factory( - msToUs(mediaItem.localConfiguration.imageDurationMs)) + msToUs(mediaItem.localConfiguration.imageDurationMs), unused -> SettableFuture.create()) .createMediaSource(mediaItem); } }