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 0c0b9c3a09..8ded3eee8b 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 @@ -18,6 +18,7 @@ package androidx.media3.exoplayer.source; import static androidx.media3.common.util.Assertions.checkNotNull; import static androidx.media3.common.util.Assertions.checkStateNotNull; import static androidx.media3.common.util.Util.castNonNull; +import static androidx.media3.common.util.Util.msToUs; import android.content.Context; import android.net.Uri; @@ -60,6 +61,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @@ -435,6 +437,12 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { if (scheme != null && scheme.equals(C.SSAI_SCHEME)) { return checkNotNull(serverSideAdInsertionMediaSourceFactory).createMediaSource(mediaItem); } + if (Objects.equals( + mediaItem.localConfiguration.mimeType, MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE)) { + return new ExternallyLoadedMediaSource.Factory( + msToUs(mediaItem.localConfiguration.imageDurationMs)) + .createMediaSource(mediaItem); + } @C.ContentType int type = Util.inferContentTypeForUriAndMimeType( @@ -531,8 +539,8 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { } return new ClippingMediaSource( mediaSource, - Util.msToUs(mediaItem.clippingConfiguration.startPositionMs), - Util.msToUs(mediaItem.clippingConfiguration.endPositionMs), + msToUs(mediaItem.clippingConfiguration.startPositionMs), + msToUs(mediaItem.clippingConfiguration.endPositionMs), /* enableInitialDiscontinuity= */ !mediaItem.clippingConfiguration.startsAtKeyFrame, /* allowDynamicClippingUpdates= */ mediaItem.clippingConfiguration.relativeToLiveWindow, mediaItem.clippingConfiguration.relativeToDefaultPosition); 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 new file mode 100644 index 0000000000..33b03ee20a --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaPeriod.java @@ -0,0 +1,193 @@ +/* + * 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.C; +import androidx.media3.common.Format; +import androidx.media3.common.TrackGroup; +import androidx.media3.common.util.NullableType; +import androidx.media3.decoder.DecoderInputBuffer; +import androidx.media3.exoplayer.FormatHolder; +import androidx.media3.exoplayer.LoadingInfo; +import androidx.media3.exoplayer.SeekParameters; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import com.google.common.base.Charsets; + +/** + * A {@link MediaPeriod} that puts a {@link Charsets#UTF_8}-encoded {@link Uri} into the sample + * queue as a single sample. + */ +/* package */ final class ExternallyLoadedMediaPeriod implements MediaPeriod { + + private final Format format; + private final TrackGroupArray tracks; + private final byte[] sampleData; + + // 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(); + tracks = new TrackGroupArray(new TrackGroup(format)); + sampleData = uri.toString().getBytes(Charsets.UTF_8); + } + + @Override + public void prepare(Callback callback, long positionUs) { + callback.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() { + // Do nothing. + } + + @Override + public TrackGroupArray getTrackGroups() { + return tracks; + } + + @Override + public long selectTracks( + @NullableType ExoTrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + streams[i] = null; + } + if (streams[i] == null && selections[i] != null) { + SampleStreamImpl stream = new SampleStreamImpl(); + streams[i] = stream; + streamResetFlags[i] = true; + } + } + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + // Do nothing. + } + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(long positionUs) { + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public long getNextLoadPositionUs() { + return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public boolean continueLoading(LoadingInfo loadingInfo) { + if (loadingFinished) { + return false; + } + loadingFinished = true; + return true; + } + + @Override + public boolean isLoading() { + return !loadingFinished; + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + 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; + + private int streamState; + + public SampleStreamImpl() { + streamState = STREAM_STATE_SEND_FORMAT; + } + + @Override + public boolean isReady() { + return loadingFinished; + } + + @Override + public void maybeThrowError() { + // Do nothing. + + } + + @Override + public @ReadDataResult int readData( + FormatHolder formatHolder, DecoderInputBuffer buffer, @ReadFlags int readFlags) { + + if (streamState == STREAM_STATE_END_OF_STREAM) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + + if ((readFlags & FLAG_REQUIRE_FORMAT) != 0 || streamState == STREAM_STATE_SEND_FORMAT) { + formatHolder.format = tracks.get(0).getFormat(0); + streamState = STREAM_STATE_SEND_SAMPLE; + return C.RESULT_FORMAT_READ; + } + + int sampleSize = sampleData.length; + buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); + buffer.timeUs = 0; + if ((readFlags & FLAG_OMIT_SAMPLE_DATA) == 0) { + buffer.ensureSpaceForWrite(sampleSize); + buffer.data.put(sampleData, /* offset= */ 0, sampleSize); + } + if ((readFlags & FLAG_PEEK) == 0) { + streamState = STREAM_STATE_END_OF_STREAM; + } + return C.RESULT_BUFFER_READ; + } + + @Override + public int skipData(long positionUs) { + // We should never skip our sample because the sample before any positive time is our only + // sample in the stream. + return 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 new file mode 100644 index 0000000000..de73db744b --- /dev/null +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ExternallyLoadedMediaSource.java @@ -0,0 +1,130 @@ +/* + * 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 static androidx.media3.common.util.Assertions.checkNotNull; + +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Timeline; +import androidx.media3.common.util.UnstableApi; +import androidx.media3.datasource.TransferListener; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import com.google.common.base.Charsets; + +/** + * A {@link MediaSource} for media loaded outside of the usual ExoPlayer loading mechanism. + * + *

Puts the {@link MediaItem.LocalConfiguration#uri} (encoded with {@link Charsets#UTF_8}) in a + * single sample belonging to a single {@link MediaPeriod}. + * + *

Typically used for image content that is managed by an external image management framework + * (for example, Glide). + */ +@UnstableApi +public final class ExternallyLoadedMediaSource extends BaseMediaSource { + + /** Factory for {@link ExternallyLoadedMediaSource}. */ + public static final class Factory implements MediaSource.Factory { + + private final long timelineDurationUs; + + /** + * Creates an instance. + * + * @param timelineDurationUs The duration of the {@link SinglePeriodTimeline} created. + */ + Factory(long timelineDurationUs) { + this.timelineDurationUs = timelineDurationUs; + } + + /** Does nothing. {@link ExternallyLoadedMediaSource} does not support DRM. */ + @Override + public MediaSource.Factory setDrmSessionManagerProvider( + DrmSessionManagerProvider drmSessionManagerProvider) { + return this; + } + + /** + * Does nothing. {@link ExternallyLoadedMediaSource} does not support error handling policies. + */ + @Override + public MediaSource.Factory setLoadErrorHandlingPolicy( + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + return this; + } + + @Override + public @C.ContentType int[] getSupportedTypes() { + return new int[] {C.CONTENT_TYPE_OTHER}; + } + + @Override + public ExternallyLoadedMediaSource createMediaSource(MediaItem mediaItem) { + return new ExternallyLoadedMediaSource(mediaItem, timelineDurationUs); + } + } + + private final MediaItem mediaItem; + private final Timeline timeline; + + private ExternallyLoadedMediaSource(MediaItem mediaItem, long timelineDurationUs) { + this.mediaItem = mediaItem; + this.timeline = + new SinglePeriodTimeline( + timelineDurationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* useLiveConfiguration= */ false, + /* manifest= */ null, + mediaItem); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + refreshSourceInfo(timeline); + } + + @Override + protected void releaseSourceInternal() { + // Do nothing. + } + + @Override + public MediaItem getMediaItem() { + return mediaItem; + } + + @Override + public void maybeThrowSourceInfoRefreshError() { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + checkNotNull(mediaItem.localConfiguration); + checkNotNull( + mediaItem.localConfiguration.mimeType, "Externally loaded mediaItems require a MIME type."); + return new ExternallyLoadedMediaPeriod( + mediaItem.localConfiguration.uri, mediaItem.localConfiguration.mimeType); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) {} +} 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 new file mode 100644 index 0000000000..d1d7d7b89e --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/ExternallyLoadedImagePlaybackTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (C) 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.e2etest; + +import static com.google.common.truth.Truth.assertThat; +import static org.robolectric.annotation.GraphicsMode.Mode.NATIVE; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; +import androidx.media3.common.Player; +import androidx.media3.common.util.Clock; +import androidx.media3.datasource.AssetDataSource; +import androidx.media3.datasource.DataSourceUtil; +import androidx.media3.datasource.DataSpec; +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.test.utils.CapturingRenderersFactory; +import androidx.media3.test.utils.DumpFileAsserts; +import androidx.media3.test.utils.FakeClock; +import androidx.media3.test.utils.robolectric.PlaybackOutput; +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 java.io.IOException; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.annotation.GraphicsMode; + +/** End-to-end tests using image content loaded from an injected image management framework. */ +@RunWith(AndroidJUnit4.class) +@GraphicsMode(value = NATIVE) +public final class ExternallyLoadedImagePlaybackTest { + + private static final String INPUT_FILE = "png/non-motion-photo-shortened.png"; + + @Test + public void test() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + CapturingRenderersFactory renderersFactory = + new CapturingRenderersFactory(applicationContext, /* addImageRenderer= */ true) + .setImageDecoderFactory(new CustomImageDecoderFactory()); + Clock clock = new FakeClock(/* isAutoAdvancing= */ true); + ExoPlayer player = + new ExoPlayer.Builder(applicationContext, renderersFactory).setClock(clock).build(); + PlaybackOutput playbackOutput = PlaybackOutput.register(player, renderersFactory); + long durationMs = 5 * C.MILLIS_PER_SECOND; + player.setMediaItem( + new MediaItem.Builder() + .setUri("asset:///media/" + INPUT_FILE) + .setImageDurationMs(durationMs) + .setMimeType(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) + .build()); + player.prepare(); + + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY); + long playerStartedMs = clock.elapsedRealtime(); + player.play(); + TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED); + long playbackDurationMs = clock.elapsedRealtime() - playerStartedMs; + player.release(); + + assertThat(playbackDurationMs).isAtLeast(durationMs); + DumpFileAsserts.assertOutput( + applicationContext, playbackOutput, "playbackdumps/" + INPUT_FILE + ".dump"); + } + + private static final class CustomImageDecoderFactory implements ImageDecoder.Factory { + + @Override + public @RendererCapabilities.Capabilities int supportsFormat(Format format) { + return format.sampleMimeType.equals(MimeTypes.APPLICATION_EXTERNALLY_LOADED_IMAGE) + ? RendererCapabilities.create(C.FORMAT_HANDLED) + : RendererCapabilities.create(C.FORMAT_UNSUPPORTED_TYPE); + } + + @Override + public ImageDecoder createImageDecoder() { + return new BitmapFactoryImageDecoder.Factory(ExternallyLoadedImagePlaybackTest::decode) + .createImageDecoder(); + } + } + + private static Bitmap decode(byte[] data, int length) throws ImageDecoderException { + String uriString = new String(data, Charsets.UTF_8); + AssetDataSource assetDataSource = + new AssetDataSource(ApplicationProvider.getApplicationContext()); + DataSpec dataSpec = new DataSpec(Uri.parse(uriString)); + @Nullable Bitmap bitmap; + + try { + assetDataSource.open(dataSpec); + byte[] imageData = DataSourceUtil.readToEnd(assetDataSource); + bitmap = BitmapFactory.decodeByteArray(imageData, /* offset= */ 0, imageData.length); + } catch (IOException e) { + throw new ImageDecoderException(e); + } + if (bitmap == null) { + throw new ImageDecoderException( + "Could not decode image data with BitmapFactory. uriString decoded from data = " + + uriString); + } + return bitmap; + } +} diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java index d5752d24c8..ff0947077f 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/CapturingRenderersFactory.java @@ -73,6 +73,7 @@ public class CapturingRenderersFactory implements RenderersFactory, Dumper.Dumpa private final CapturingMediaCodecAdapter.Factory mediaCodecAdapterFactory; private final CapturingAudioSink audioSink; private final CapturingImageOutput imageOutput; + private ImageDecoder.Factory imageDecoderFactory; /** * Creates an instance. @@ -96,6 +97,23 @@ public class CapturingRenderersFactory implements RenderersFactory, Dumper.Dumpa this.audioSink = new CapturingAudioSink(new DefaultAudioSink.Builder(context).build()); this.imageOutput = new CapturingImageOutput(); this.addImageRenderer = addImageRenderer; + this.imageDecoderFactory = ImageDecoder.Factory.DEFAULT; + } + + /** + * Sets the {@link ImageDecoder.Factory} used by the {@link ImageRenderer}. + * + *

Must {@code addImageRenderer} when creating the {@link + * CapturingRenderersFactory#CapturingRenderersFactory(Context, boolean)}. + * + * @param imageDecoderFactory The {@link ImageDecoder.Factory}. + * @return This factory, for convenience. + */ + public CapturingRenderersFactory setImageDecoderFactory( + ImageDecoder.Factory imageDecoderFactory) { + checkState(addImageRenderer); + this.imageDecoderFactory = imageDecoderFactory; + return this; } @Override @@ -149,7 +167,7 @@ public class CapturingRenderersFactory implements RenderersFactory, Dumper.Dumpa temp.add(new MetadataRenderer(metadataRendererOutput, eventHandler.getLooper())); if (addImageRenderer) { - temp.add(new ImageRenderer(ImageDecoder.Factory.DEFAULT, imageOutput)); + temp.add(new ImageRenderer(imageDecoderFactory, imageOutput)); } return temp.toArray(new Renderer[] {}); }