diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 36a2dba905..527f76d026 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -40,6 +40,10 @@ when provided while processing `onOutputFormatChanged` ([#1371](https://github.com/androidx/media/pull/1371)). * Text: + * Stop eagerly loading all subtitle files configured with + `MediaItem.Builder.setSubtitleConfigurations`, and instead only load one + if it is selected by track selection + ([#1721](https://github.com/androidx/media/issues/1721)). * Metadata: * Image: * DRM: 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 f94a3198cd..1902a98e64 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 @@ -525,12 +525,23 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { () -> new Extractor[] { subtitleParserFactory.supportsFormat(format) - ? new SubtitleExtractor(subtitleParserFactory.create(format), format) + ? new SubtitleExtractor( + subtitleParserFactory.create(format), /* format= */ null) : new UnknownSubtitlesExtractor(format) }; ProgressiveMediaSource.Factory progressiveMediaSourceFactory = new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) - .setSuppressPrepareError(true); + .enableLazyLoadingWithSingleTrack( + SubtitleExtractor.TRACK_ID, + subtitleParserFactory.supportsFormat(format) + ? format + .buildUpon() + .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) + .setCodecs(format.sampleMimeType) + .setCueReplacementBehavior( + subtitleParserFactory.getCueReplacementBehavior(format)) + .build() + : format); if (loadErrorHandlingPolicy != null) { progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); } @@ -792,7 +803,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { @Override public void init(ExtractorOutput output) { - TrackOutput trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_TEXT); + TrackOutput trackOutput = output.track(SubtitleExtractor.TRACK_ID, C.TRACK_TYPE_TEXT); output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); output.endTracks(); trackOutput.format( diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java index 4cade3e65a..2d014eb761 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriod.java @@ -59,6 +59,7 @@ import androidx.media3.extractor.DiscardingTrackOutput; import androidx.media3.extractor.Extractor; import androidx.media3.extractor.ExtractorOutput; import androidx.media3.extractor.ForwardingSeekMap; +import androidx.media3.extractor.IndexSeekMap; import androidx.media3.extractor.PositionHolder; import androidx.media3.extractor.SeekMap; import androidx.media3.extractor.SeekMap.SeekPoints; @@ -119,7 +120,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private final Allocator allocator; @Nullable private final String customCacheKey; private final long continueLoadingCheckIntervalBytes; - private final boolean suppressPrepareError; + private final int singleTrackId; + @Nullable private final Format singleTrackFormat; private final long singleSampleDurationUs; private final Loader loader; private final ProgressiveMediaExtractor progressiveMediaExtractor; @@ -173,9 +175,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; * indexing. May be null. * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each * invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}. - * @param suppressPrepareError True if an error that would be thrown from {@link - * #maybeThrowPrepareError()} should instead be suppressed and allow preparation to - * {@linkplain Callback#onPrepared complete}. + * @param singleTrackId The ID of the track configured by {@code singleTrackFormat}. Ignored if + * {@code singleTrackFormat} is null. + * @param singleTrackFormat The format of the single track this period is known to emit, allowing + * preparation to complete without reading any data. Otherwise null. * @param singleSampleDurationUs The duration of media with a single sample in microseconds. * @param downloadExecutor An optional externally provided {@link ReleasableExecutor} for loading * and extracting media. @@ -194,7 +197,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; Allocator allocator, @Nullable String customCacheKey, int continueLoadingCheckIntervalBytes, - boolean suppressPrepareError, + int singleTrackId, + @Nullable Format singleTrackFormat, long singleSampleDurationUs, @Nullable ReleasableExecutor downloadExecutor) { this.uri = uri; @@ -207,7 +211,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; this.allocator = allocator; this.customCacheKey = customCacheKey; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; - this.suppressPrepareError = suppressPrepareError; + this.singleTrackId = singleTrackId; + this.singleTrackFormat = singleTrackFormat; loader = downloadExecutor != null ? new Loader(downloadExecutor) @@ -254,23 +259,28 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; @Override public void prepare(Callback callback, long positionUs) { this.callback = callback; - loadCondition.open(); - startLoading(); + if (singleTrackFormat != null) { + // track() and endTracks() are meant to be called on the loading thread, which doesn't exist + // yet (we're on the playback thread here). Starting the loading thread will provide a memory + // barrier to ensure any changes done here are visible on the loading thread after it starts. + TrackOutput track = track(singleTrackId, C.TRACK_TYPE_TEXT); + track.format(singleTrackFormat); + setSeekMap( + new IndexSeekMap( + /* positions= */ new long[] {0}, + /* timesUs= */ new long[] {0}, + /* durationUs= */ C.TIME_UNSET)); + endTracks(); + pendingResetPositionUs = positionUs; + } else { + loadCondition.open(); + startLoading(); + } } @Override public void maybeThrowPrepareError() throws IOException { - try { - maybeThrowError(); - } catch (IOException e) { - if (suppressPrepareError) { - Log.e(TAG, "Suppressing preparation error because suppressPrepareError=true", e); - sampleQueuesBuilt = true; - setSeekMap(new Unseekable(C.TIME_UNSET)); - } else { - throw e; - } - } + maybeThrowError(); if (loadingFinished && !prepared) { throw ParserException.createForMalformedContainer( "Loading finished before preparation is complete.", /* cause= */ null); @@ -390,7 +400,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; if (loadingFinished || loader.hasFatalError() || pendingDeferredRetry - || (prepared && enabledTrackCount == 0)) { + || ((prepared || singleTrackFormat != null) && enabledTrackCount == 0)) { return false; } boolean continuedLoading = loadCondition.open(); @@ -574,9 +584,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; private void maybeStartDeferredRetry(int track) { assertPrepared(); - boolean[] trackIsAudioVideoFlags = trackState.trackIsAudioVideoFlags; if (!pendingDeferredRetry - || !trackIsAudioVideoFlags[track] + || (haveAudioVideoTracks && !trackState.trackIsAudioVideoFlags[track]) || sampleQueues[track].isReady(/* loadingFinished= */ false)) { return; } diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaSource.java index 02176db5b9..4ed305e187 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ProgressiveMediaSource.java @@ -22,6 +22,7 @@ import android.os.Looper; import androidx.annotation.GuardedBy; import androidx.annotation.Nullable; import androidx.media3.common.C; +import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.Timeline; import androidx.media3.common.util.Consumer; @@ -32,13 +33,16 @@ import androidx.media3.datasource.TransferListener; import androidx.media3.exoplayer.drm.DefaultDrmSessionManagerProvider; import androidx.media3.exoplayer.drm.DrmSessionManager; import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.util.ReleasableExecutor; import androidx.media3.extractor.DefaultExtractorsFactory; import androidx.media3.extractor.Extractor; +import androidx.media3.extractor.ExtractorOutput; import androidx.media3.extractor.ExtractorsFactory; +import androidx.media3.extractor.TrackOutput; import com.google.common.base.Supplier; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.util.concurrent.Executor; @@ -69,7 +73,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private int continueLoadingCheckIntervalBytes; @Nullable private Supplier downloadExecutorSupplier; - private boolean suppressPrepareError; + private int singleTrackId; + @Nullable private Format singleTrackFormat; /** * Creates a new factory for {@link ProgressiveMediaSource}s. @@ -191,17 +196,21 @@ public final class ProgressiveMediaSource extends BaseMediaSource } /** - * Allow {@link MediaPeriod} preparation to {@linkplain - * MediaPeriod.Callback#onPrepared(MediaPeriod) complete} despite an error that would have - * otherwise blocked it. + * Allows the {@link ProgressiveMediaSource} to complete preparation without reading any data. * - *

If set to true, an error that would normally be thrown from {@link - * MediaPeriod#maybeThrowPrepareError()} (e.g. a {@link DataSource#open} error like HTTP 404) is - * instead suppressed and preparation is completed with no tracks. + *

This must only be set if the source is guaranteed to emit a single track with the provided + * ID and format. + * + *

Data will only be loaded if the track is selected with {@link + * MediaPeriod#selectTracks(ExoTrackSelection[], boolean[], SampleStream[], boolean[], long)} + * + * @param trackId The ID of the track to pass to {@link ExtractorOutput#track} + * @param format The format of the track to pass to {@link TrackOutput#format}. */ @CanIgnoreReturnValue - /* package */ Factory setSuppressPrepareError(boolean suppressPrepareError) { - this.suppressPrepareError = suppressPrepareError; + /* package */ Factory enableLazyLoadingWithSingleTrack(int trackId, Format format) { + this.singleTrackId = trackId; + this.singleTrackFormat = checkNotNull(format); return this; } @@ -252,7 +261,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource drmSessionManagerProvider.get(mediaItem), loadErrorHandlingPolicy, continueLoadingCheckIntervalBytes, - suppressPrepareError, + singleTrackId, + singleTrackFormat, downloadExecutorSupplier); } @@ -273,7 +283,19 @@ public final class ProgressiveMediaSource extends BaseMediaSource private final DrmSessionManager drmSessionManager; private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; private final int continueLoadingCheckIntervalBytes; - private final boolean suppressPrepareError; + + /** + * The ID passed to {@link Factory#enableLazyLoadingWithSingleTrack(int, Format)}. Only valid if + * {@link #singleTrackFormat} is non-null. + */ + private final int singleTrackId; + + /** + * The {@link Format} passed to {@link Factory#enableLazyLoadingWithSingleTrack(int, Format)}, or + * {@code null} if not set. + */ + @Nullable private final Format singleTrackFormat; + @Nullable private final Supplier downloadExecutorSupplier; private boolean timelineIsPlaceholder; @@ -292,7 +314,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource DrmSessionManager drmSessionManager, LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, int continueLoadingCheckIntervalBytes, - boolean suppressPrepareError, + int singleTrackId, + @Nullable Format singleTrackFormat, @Nullable Supplier downloadExecutorSupplier) { this.mediaItem = mediaItem; this.dataSourceFactory = dataSourceFactory; @@ -300,7 +323,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource this.drmSessionManager = drmSessionManager; this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; - this.suppressPrepareError = suppressPrepareError; + this.singleTrackFormat = singleTrackFormat; + this.singleTrackId = singleTrackId; this.timelineIsPlaceholder = true; this.timelineDurationUs = C.TIME_UNSET; this.downloadExecutorSupplier = downloadExecutorSupplier; @@ -359,7 +383,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource allocator, localConfiguration.customCacheKey, continueLoadingCheckIntervalBytes, - suppressPrepareError, + singleTrackId, + singleTrackFormat, Util.msToUs(localConfiguration.imageDurationMs), downloadExecutorSupplier != null ? downloadExecutorSupplier.get() : null); } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java index e2fec4d0f9..c2989bd699 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/e2etest/SubtitlePlaybackTest.java @@ -42,6 +42,8 @@ import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; import com.google.common.collect.ImmutableList; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicReference; import org.junit.Rule; import org.junit.Test; @@ -55,6 +57,56 @@ public class SubtitlePlaybackTest { public ShadowMediaCodecConfig mediaCodecConfig = ShadowMediaCodecConfig.forAllSupportedMimeTypes(); + // https://github.com/androidx/media/issues/1721 + @Test + public void multipleSideloadedSubtitles_noneSelected_noneLoaded() throws Exception { + Context applicationContext = ApplicationProvider.getApplicationContext(); + List loadStartedUris = new ArrayList<>(); + AnalyticsListener analyticsListener = + new AnalyticsListener() { + @Override + public void onLoadStarted( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + int retryCount) { + loadStartedUris.add(loadEventInfo.uri); + loadStartedUris.add(loadEventInfo.dataSpec.uri); + } + }; + ExoPlayer player = + new ExoPlayer.Builder(applicationContext) + .setClock(new FakeClock(/* isAutoAdvancing= */ true)) + .build(); + player.addAnalyticsListener(analyticsListener); + Uri typicalVttUri = Uri.parse("asset:///media/webvtt/typical"); + Uri simpleTtmlUri = Uri.parse("asset:///media/ttml/simple.xml"); + MediaItem mediaItem = + new MediaItem.Builder() + .setUri("asset:///media/mp4/sample.mp4") + .setSubtitleConfigurations( + ImmutableList.of( + new MediaItem.SubtitleConfiguration.Builder(typicalVttUri) + .setMimeType(MimeTypes.TEXT_VTT) + .setLanguage("en") + .build(), + new MediaItem.SubtitleConfiguration.Builder(simpleTtmlUri) + .setMimeType(MimeTypes.APPLICATION_TTML) + .setLanguage("en") + .build())) + .build(); + + player.setMediaItem(mediaItem); + player.prepare(); + run(player).untilState(Player.STATE_READY); + run(player).untilLoadingIs(false); + player.play(); + run(player).untilState(Player.STATE_ENDED); + player.release(); + + assertThat(loadStartedUris).containsNoneOf(typicalVttUri, simpleTtmlUri); + } + @Test public void sideloadedSubtitleLoadingError_playbackContinues_errorReportedToAnalyticsListener() throws Exception { @@ -105,9 +157,8 @@ public class SubtitlePlaybackTest { surface.release(); assertThat(loadErrorEventInfo.get().uri).isEqualTo(notFoundSubtitleUri); - // Assert the output is the same as playing the video without sideloaded subtitles. DumpFileAsserts.assertOutput( - applicationContext, playbackOutput, "playbackdumps/mp4/sample.mp4.dump"); + applicationContext, playbackOutput, "playbackdumps/subtitles/sideloaded-error.mp4.dump"); } @Test @@ -172,8 +223,6 @@ public class SubtitlePlaybackTest { .hasMessageThat() .contains("test subtitle parsing error"); DumpFileAsserts.assertOutput( - applicationContext, - playbackOutput, - "playbackdumps/subtitles/sideloaded-parse-error.mp4.dump"); + applicationContext, playbackOutput, "playbackdumps/subtitles/sideloaded-error.mp4.dump"); } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriodTest.java index 5134603e84..844be1974e 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaPeriodTest.java @@ -121,7 +121,8 @@ public final class ProgressiveMediaPeriodTest { new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), /* customCacheKey= */ null, ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES, - /* suppressPrepareError= */ false, + /* singleTrackId= */ 0, + /* singleTrackFormat= */ null, imageDurationUs, executor != null ? ReleasableExecutor.from(executor, executorReleased) : null); diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java index 44b6454570..f882d53b0d 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ProgressiveMediaSourceTest.java @@ -15,24 +15,39 @@ */ package androidx.media3.exoplayer.source; +import static androidx.media3.common.util.Assertions.checkNotNull; +import static androidx.media3.common.util.Assertions.checkState; import static androidx.media3.test.utils.robolectric.RobolectricUtil.DEFAULT_TIMEOUT_MS; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; -import android.os.SystemClock; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; import androidx.annotation.Nullable; +import androidx.media3.common.Format; import androidx.media3.common.MediaItem; +import androidx.media3.common.MimeTypes; import androidx.media3.common.Timeline; +import androidx.media3.common.util.ConditionVariable; import androidx.media3.common.util.Util; +import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.datasource.ResolvingDataSource; +import androidx.media3.exoplayer.LoadingInfo; import androidx.media3.exoplayer.analytics.PlayerId; +import androidx.media3.exoplayer.trackselection.ExoTrackSelection; +import androidx.media3.exoplayer.trackselection.FixedTrackSelection; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.test.utils.MediaSourceTestRunner; import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.util.concurrent.ListenableFuture; import java.io.IOException; +import java.util.HashSet; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; import org.junit.Test; @@ -119,14 +134,91 @@ public class ProgressiveMediaSourceTest { } @Test - public void maybeThrowPrepareError_withSuppressPrepareError_doesNotThrow() throws Exception { + public void lazyLoading_preparationCompletesWithoutLoadingData_loadsDataWhenTrackSelected() + throws Exception { + Set openedUris = new HashSet<>(); + DataSource.Factory dataSourceFactory = + new ResolvingDataSource.Factory( + new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext()), + dataSpec -> { + openedUris.add(dataSpec.uri); + return dataSpec; + }); + Uri mediaUri = Uri.parse("asset:///media/mp4/sample_opus.mp4"); + Format format = + new Format.Builder().setId("format ID").setSampleMimeType(MimeTypes.AUDIO_OPUS).build(); + ProgressiveMediaSource mediaSource = + new ProgressiveMediaSource.Factory(dataSourceFactory) + .enableLazyLoadingWithSingleTrack(/* trackId= */ 42, format) + .createMediaSource(MediaItem.fromUri(mediaUri)); + MediaSourceTestRunner mediaSourceTestRunner = new MediaSourceTestRunner(mediaSource); + ConditionVariable loadCompleted = new ConditionVariable(); + mediaSourceTestRunner.runOnPlaybackThread( + () -> + mediaSource.addEventListener( + new Handler(checkNotNull(Looper.myLooper())), + new MediaSourceEventListener() { + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaSource.MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + loadCompleted.open(); + } + })); + + Timeline timeline = mediaSourceTestRunner.prepareSource(); + MediaPeriod mediaPeriod = + mediaSourceTestRunner.createPeriod( + new MediaSource.MediaPeriodId( + timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); + CountDownLatch preparedLatch = + mediaSourceTestRunner.preparePeriod(mediaPeriod, /* positionUs= */ 0); + + assertThat(preparedLatch.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(openedUris).isEmpty(); + + ListenableFuture isLoading = + mediaSourceTestRunner.asyncRunOnPlaybackThread( + () -> { + mediaPeriod.continueLoading( + new LoadingInfo.Builder().setPlaybackPositionUs(0).build()); + return mediaPeriod.isLoading(); + }); + assertThat(isLoading.get()).isFalse(); + + isLoading = + mediaSourceTestRunner.asyncRunOnPlaybackThread( + () -> { + selectOnlyTrack(mediaPeriod); + mediaPeriod.continueLoading( + new LoadingInfo.Builder().setPlaybackPositionUs(0).build()); + return mediaPeriod.isLoading(); + }); + assertThat(isLoading.get()).isTrue(); + + loadCompleted.block(); + + assertThat(mediaSourceTestRunner.asyncRunOnPlaybackThread(mediaPeriod::isLoading).get()) + .isFalse(); + assertThat(openedUris).containsExactly(mediaUri); + + mediaSourceTestRunner.releasePeriod(mediaPeriod); + mediaSourceTestRunner.releaseSource(); + mediaSourceTestRunner.release(); + } + + @Test + public void lazyLoading_notFoundUri_loadErrorReportedWhenTrackSelected() throws Exception { ProgressiveMediaSource mediaSource = new ProgressiveMediaSource.Factory( new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())) // Disable retries, so the first error is marked fatal. .setLoadErrorHandlingPolicy( new DefaultLoadErrorHandlingPolicy(/* minimumLoadableRetryCount= */ 0)) - .setSuppressPrepareError(true) + .enableLazyLoadingWithSingleTrack( + /* trackId= */ 42, new Format.Builder().setId("format ID").build()) .createMediaSource(MediaItem.fromUri("file:///not/found")); MediaSourceTestRunner mediaSourceTestRunner = new MediaSourceTestRunner(mediaSource); @@ -154,27 +246,19 @@ public class ProgressiveMediaSourceTest { timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); CountDownLatch preparedLatch = mediaSourceTestRunner.preparePeriod(mediaPeriod, /* positionUs= */ 0); - assertThat(loadErrorReported.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); - // Call maybeThrowPrepareError() in a loop until preparation completes (preparation is not - // unblocked until the error is caught and suppressed inside maybeThrowPrepareError()). This - // mimics the behaviour of ExoPlayerImplInternal which calls maybeThrowPrepareError() on - // un-prepared MediaPeriods on every doSomeWork() iteration. - long startTime = SystemClock.elapsedRealtime(); - do { - AtomicReference prepareError = new AtomicReference<>(); - mediaSourceTestRunner.runOnPlaybackThread( - () -> { - try { - mediaPeriod.maybeThrowPrepareError(); - } catch (Throwable e) { - prepareError.set(e); - } - }); - assertThat(prepareError.get()).isNull(); - } while (preparedLatch.getCount() > 0 - && (SystemClock.elapsedRealtime() - startTime) < DEFAULT_TIMEOUT_MS); assertThat(preparedLatch.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); + ListenableFuture isLoading = + mediaSourceTestRunner.asyncRunOnPlaybackThread( + () -> { + selectOnlyTrack(mediaPeriod); + mediaPeriod.continueLoading( + new LoadingInfo.Builder().setPlaybackPositionUs(0).build()); + return mediaPeriod.isLoading(); + }); + assertThat(isLoading.get()).isTrue(); + assertThat(loadErrorReported.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); + mediaSourceTestRunner.releasePeriod(mediaPeriod); mediaSourceTestRunner.releaseSource(); mediaSourceTestRunner.release(); @@ -185,4 +269,14 @@ public class ProgressiveMediaSourceTest { new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())) .createMediaSource(mediaItem); } + + private static void selectOnlyTrack(MediaPeriod mediaPeriod) { + checkState(mediaPeriod.getTrackGroups().length == 1); + mediaPeriod.selectTracks( + new ExoTrackSelection[] {new FixedTrackSelection(mediaPeriod.getTrackGroups().get(0), 0)}, + /* mayRetainStreamFlags= */ new boolean[] {false}, + new SampleStream[1], + /* streamResetFlags= */ new boolean[] {false}, + /* positionUs= */ 0); + } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/SubtitleExtractor.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/SubtitleExtractor.java index 9e5492f8c1..277f4a8d44 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/SubtitleExtractor.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/SubtitleExtractor.java @@ -20,6 +20,7 @@ import static androidx.media3.common.util.Assertions.checkStateNotNull; import static java.lang.annotation.ElementType.TYPE_USE; import androidx.annotation.IntDef; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; @@ -48,6 +49,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull; /** Generic extractor for extracting subtitles from various subtitle formats. */ @UnstableApi public class SubtitleExtractor implements Extractor { + + /** The ID of the single track emitted by this extractor. */ + public static final int TRACK_ID = 0; + @Documented @Retention(RetentionPolicy.SOURCE) @Target(TYPE_USE) @@ -83,7 +88,7 @@ public class SubtitleExtractor implements Extractor { private final SubtitleParser subtitleParser; private final CueEncoder cueEncoder; - private final Format format; + @Nullable private final Format format; private final List samples; private final ParsableByteArray scratchSampleArray; @@ -99,20 +104,26 @@ public class SubtitleExtractor implements Extractor { * * @param subtitleParser The parser used for parsing the subtitle data. The extractor will reset * the parser in {@link SubtitleExtractor#release()}. - * @param format {@link Format} that describes subtitle data. + * @param format {@link Format} that describes subtitle data. Can be null if {@link + * TrackOutput#format}, {@link ExtractorOutput#seekMap} and {@link + * ExtractorOutput#endTracks()} will be called outside this extractor. */ - public SubtitleExtractor(SubtitleParser subtitleParser, Format format) { + public SubtitleExtractor(SubtitleParser subtitleParser, @Nullable Format format) { this.subtitleParser = subtitleParser; cueEncoder = new CueEncoder(); subtitleData = Util.EMPTY_BYTE_ARRAY; scratchSampleArray = new ParsableByteArray(); + // TODO: b/376693592 - Simplify this by taking the post-transformation Format as a parameter + // instead. this.format = - format - .buildUpon() - .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) - .setCodecs(format.sampleMimeType) - .setCueReplacementBehavior(subtitleParser.getCueReplacementBehavior()) - .build(); + format != null + ? format + .buildUpon() + .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) + .setCodecs(format.sampleMimeType) + .setCueReplacementBehavior(subtitleParser.getCueReplacementBehavior()) + .build() + : null; samples = new ArrayList<>(); state = STATE_CREATED; timestamps = Util.EMPTY_LONG_ARRAY; @@ -130,14 +141,16 @@ public class SubtitleExtractor implements Extractor { @Override public void init(ExtractorOutput output) { checkState(state == STATE_CREATED); - trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_TEXT); - trackOutput.format(format); - output.endTracks(); - output.seekMap( - new IndexSeekMap( - /* positions= */ new long[] {0}, - /* timesUs= */ new long[] {0}, - /* durationUs= */ C.TIME_UNSET)); + trackOutput = output.track(TRACK_ID, C.TRACK_TYPE_TEXT); + if (format != null) { + trackOutput.format(format); + output.endTracks(); + output.seekMap( + new IndexSeekMap( + /* positions= */ new long[] {0}, + /* timesUs= */ new long[] {0}, + /* durationUs= */ C.TIME_UNSET)); + } state = STATE_INITIALIZED; } diff --git a/libraries/test_data/src/test/assets/playbackdumps/subtitles/sideloaded-parse-error.mp4.dump b/libraries/test_data/src/test/assets/playbackdumps/subtitles/sideloaded-error.mp4.dump similarity index 100% rename from libraries/test_data/src/test/assets/playbackdumps/subtitles/sideloaded-parse-error.mp4.dump rename to libraries/test_data/src/test/assets/playbackdumps/subtitles/sideloaded-error.mp4.dump diff --git a/libraries/test_utils/src/main/java/androidx/media3/test/utils/MediaSourceTestRunner.java b/libraries/test_utils/src/main/java/androidx/media3/test/utils/MediaSourceTestRunner.java index ac0c393c71..ee9fb97b6f 100644 --- a/libraries/test_utils/src/main/java/androidx/media3/test/utils/MediaSourceTestRunner.java +++ b/libraries/test_utils/src/main/java/androidx/media3/test/utils/MediaSourceTestRunner.java @@ -42,13 +42,17 @@ import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller; import androidx.media3.exoplayer.source.MediaSourceEventListener; import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.DefaultAllocator; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; +import java.util.concurrent.Callable; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; import java.util.concurrent.LinkedBlockingDeque; import org.checkerframework.dataflow.qual.SideEffectFree; @@ -93,26 +97,35 @@ public class MediaSourceTestRunner { * @param runnable The {@link Runnable} to run. */ public void runOnPlaybackThread(final Runnable runnable) { - Throwable[] throwable = new Throwable[1]; - CountDownLatch finishedLatch = new CountDownLatch(1); + ListenableFuture result = + asyncRunOnPlaybackThread( + () -> { + runnable.run(); + return null; + }); + try { + result.get(); + } catch (InterruptedException | ExecutionException e) { + Util.sneakyThrow(e); + } + } + + /** + * Runs the provided {@link Callable} on the playback thread and returns a future of the result. + * + * @param callable The {@link Callable} to run. + */ + public ListenableFuture asyncRunOnPlaybackThread(Callable callable) { + SettableFuture result = SettableFuture.create(); playbackHandler.post( () -> { try { - runnable.run(); + result.set(callable.call()); } catch (Throwable e) { - throwable[0] = e; - } finally { - finishedLatch.countDown(); + result.setException(e); } }); - try { - assertThat(finishedLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue(); - } catch (InterruptedException e) { - Util.sneakyThrow(e); - } - if (throwable[0] != null) { - Util.sneakyThrow(throwable[0]); - } + return result; } /**