Support customized loading in ExternallyLoadedMediaSource

PiperOrigin-RevId: 572299971
This commit is contained in:
tofunmi 2023-10-10 10:41:43 -07:00 committed by Copybara-Service
parent 66fa591959
commit 8f2161c43d
6 changed files with 235 additions and 33 deletions

View file

@ -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).
*
* <p>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

View file

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

View file

@ -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<Throwable> 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;

View file

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

View file

@ -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

View file

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