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);
}
}