Configure ProgressiveMediaSource with a Format for subtitles

This means we can complete preparation (and trigger track selection)
before opening a `DataSource`, which then means we only end up loading
the data for a selected subtitle track (instead of all tracks as
currently happens).

By making preparation trivial in this case (with no reasonable cause
of error), we can also remove the `suppressPrepareError` option added in
b3290eff10.

This change also fixes the implementation of
`ProgressiveMediaPeriod.maybeStartDeferredRetry` to only short-circuit
return `false` if the chosen track is not audio or video **and** there
is at least one audio or video track in this period.

Issue: androidx/media#1721
PiperOrigin-RevId: 702275968
This commit is contained in:
ibaker 2024-12-03 03:37:52 -08:00 committed by Copybara-Service
parent 0037388660
commit 18d156fe3c
10 changed files with 317 additions and 98 deletions

View File

@ -40,6 +40,10 @@
when provided while processing `onOutputFormatChanged` when provided while processing `onOutputFormatChanged`
([#1371](https://github.com/androidx/media/pull/1371)). ([#1371](https://github.com/androidx/media/pull/1371)).
* Text: * 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: * Metadata:
* Image: * Image:
* DRM: * DRM:

View File

@ -525,12 +525,23 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory {
() -> () ->
new Extractor[] { new Extractor[] {
subtitleParserFactory.supportsFormat(format) subtitleParserFactory.supportsFormat(format)
? new SubtitleExtractor(subtitleParserFactory.create(format), format) ? new SubtitleExtractor(
subtitleParserFactory.create(format), /* format= */ null)
: new UnknownSubtitlesExtractor(format) : new UnknownSubtitlesExtractor(format)
}; };
ProgressiveMediaSource.Factory progressiveMediaSourceFactory = ProgressiveMediaSource.Factory progressiveMediaSourceFactory =
new ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) 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) { if (loadErrorHandlingPolicy != null) {
progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy); progressiveMediaSourceFactory.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy);
} }
@ -792,7 +803,7 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory {
@Override @Override
public void init(ExtractorOutput output) { 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.seekMap(new SeekMap.Unseekable(C.TIME_UNSET));
output.endTracks(); output.endTracks();
trackOutput.format( trackOutput.format(

View File

@ -59,6 +59,7 @@ import androidx.media3.extractor.DiscardingTrackOutput;
import androidx.media3.extractor.Extractor; import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorOutput; import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ForwardingSeekMap; import androidx.media3.extractor.ForwardingSeekMap;
import androidx.media3.extractor.IndexSeekMap;
import androidx.media3.extractor.PositionHolder; import androidx.media3.extractor.PositionHolder;
import androidx.media3.extractor.SeekMap; import androidx.media3.extractor.SeekMap;
import androidx.media3.extractor.SeekMap.SeekPoints; import androidx.media3.extractor.SeekMap.SeekPoints;
@ -119,7 +120,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private final Allocator allocator; private final Allocator allocator;
@Nullable private final String customCacheKey; @Nullable private final String customCacheKey;
private final long continueLoadingCheckIntervalBytes; private final long continueLoadingCheckIntervalBytes;
private final boolean suppressPrepareError; private final int singleTrackId;
@Nullable private final Format singleTrackFormat;
private final long singleSampleDurationUs; private final long singleSampleDurationUs;
private final Loader loader; private final Loader loader;
private final ProgressiveMediaExtractor progressiveMediaExtractor; private final ProgressiveMediaExtractor progressiveMediaExtractor;
@ -173,9 +175,10 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
* indexing. May be null. * indexing. May be null.
* @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each
* invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}. * invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}.
* @param suppressPrepareError True if an error that would be thrown from {@link * @param singleTrackId The ID of the track configured by {@code singleTrackFormat}. Ignored if
* #maybeThrowPrepareError()} should instead be suppressed and allow preparation to * {@code singleTrackFormat} is null.
* {@linkplain Callback#onPrepared complete}. * @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 singleSampleDurationUs The duration of media with a single sample in microseconds.
* @param downloadExecutor An optional externally provided {@link ReleasableExecutor} for loading * @param downloadExecutor An optional externally provided {@link ReleasableExecutor} for loading
* and extracting media. * and extracting media.
@ -194,7 +197,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
Allocator allocator, Allocator allocator,
@Nullable String customCacheKey, @Nullable String customCacheKey,
int continueLoadingCheckIntervalBytes, int continueLoadingCheckIntervalBytes,
boolean suppressPrepareError, int singleTrackId,
@Nullable Format singleTrackFormat,
long singleSampleDurationUs, long singleSampleDurationUs,
@Nullable ReleasableExecutor downloadExecutor) { @Nullable ReleasableExecutor downloadExecutor) {
this.uri = uri; this.uri = uri;
@ -207,7 +211,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
this.allocator = allocator; this.allocator = allocator;
this.customCacheKey = customCacheKey; this.customCacheKey = customCacheKey;
this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes;
this.suppressPrepareError = suppressPrepareError; this.singleTrackId = singleTrackId;
this.singleTrackFormat = singleTrackFormat;
loader = loader =
downloadExecutor != null downloadExecutor != null
? new Loader(downloadExecutor) ? new Loader(downloadExecutor)
@ -254,23 +259,28 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
@Override @Override
public void prepare(Callback callback, long positionUs) { public void prepare(Callback callback, long positionUs) {
this.callback = callback; this.callback = callback;
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(); loadCondition.open();
startLoading(); startLoading();
} }
}
@Override @Override
public void maybeThrowPrepareError() throws IOException { public void maybeThrowPrepareError() throws IOException {
try {
maybeThrowError(); 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;
}
}
if (loadingFinished && !prepared) { if (loadingFinished && !prepared) {
throw ParserException.createForMalformedContainer( throw ParserException.createForMalformedContainer(
"Loading finished before preparation is complete.", /* cause= */ null); "Loading finished before preparation is complete.", /* cause= */ null);
@ -390,7 +400,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
if (loadingFinished if (loadingFinished
|| loader.hasFatalError() || loader.hasFatalError()
|| pendingDeferredRetry || pendingDeferredRetry
|| (prepared && enabledTrackCount == 0)) { || ((prepared || singleTrackFormat != null) && enabledTrackCount == 0)) {
return false; return false;
} }
boolean continuedLoading = loadCondition.open(); boolean continuedLoading = loadCondition.open();
@ -574,9 +584,8 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
private void maybeStartDeferredRetry(int track) { private void maybeStartDeferredRetry(int track) {
assertPrepared(); assertPrepared();
boolean[] trackIsAudioVideoFlags = trackState.trackIsAudioVideoFlags;
if (!pendingDeferredRetry if (!pendingDeferredRetry
|| !trackIsAudioVideoFlags[track] || (haveAudioVideoTracks && !trackState.trackIsAudioVideoFlags[track])
|| sampleQueues[track].isReady(/* loadingFinished= */ false)) { || sampleQueues[track].isReady(/* loadingFinished= */ false)) {
return; return;
} }

View File

@ -22,6 +22,7 @@ import android.os.Looper;
import androidx.annotation.GuardedBy; import androidx.annotation.GuardedBy;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline; import androidx.media3.common.Timeline;
import androidx.media3.common.util.Consumer; 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.DefaultDrmSessionManagerProvider;
import androidx.media3.exoplayer.drm.DrmSessionManager; import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.util.ReleasableExecutor; import androidx.media3.exoplayer.util.ReleasableExecutor;
import androidx.media3.extractor.DefaultExtractorsFactory; import androidx.media3.extractor.DefaultExtractorsFactory;
import androidx.media3.extractor.Extractor; import androidx.media3.extractor.Extractor;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.extractor.ExtractorsFactory; import androidx.media3.extractor.ExtractorsFactory;
import androidx.media3.extractor.TrackOutput;
import com.google.common.base.Supplier; import com.google.common.base.Supplier;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -69,7 +73,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource
private LoadErrorHandlingPolicy loadErrorHandlingPolicy; private LoadErrorHandlingPolicy loadErrorHandlingPolicy;
private int continueLoadingCheckIntervalBytes; private int continueLoadingCheckIntervalBytes;
@Nullable private Supplier<ReleasableExecutor> downloadExecutorSupplier; @Nullable private Supplier<ReleasableExecutor> downloadExecutorSupplier;
private boolean suppressPrepareError; private int singleTrackId;
@Nullable private Format singleTrackFormat;
/** /**
* Creates a new factory for {@link ProgressiveMediaSource}s. * Creates a new factory for {@link ProgressiveMediaSource}s.
@ -191,17 +196,21 @@ public final class ProgressiveMediaSource extends BaseMediaSource
} }
/** /**
* Allow {@link MediaPeriod} preparation to {@linkplain * Allows the {@link ProgressiveMediaSource} to complete preparation without reading any data.
* MediaPeriod.Callback#onPrepared(MediaPeriod) complete} despite an error that would have
* otherwise blocked it.
* *
* <p>If set to true, an error that would normally be thrown from {@link * <p>This must only be set if the source is guaranteed to emit a single track with the provided
* MediaPeriod#maybeThrowPrepareError()} (e.g. a {@link DataSource#open} error like HTTP 404) is * ID and format.
* instead suppressed and preparation is completed with no tracks. *
* <p>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 @CanIgnoreReturnValue
/* package */ Factory setSuppressPrepareError(boolean suppressPrepareError) { /* package */ Factory enableLazyLoadingWithSingleTrack(int trackId, Format format) {
this.suppressPrepareError = suppressPrepareError; this.singleTrackId = trackId;
this.singleTrackFormat = checkNotNull(format);
return this; return this;
} }
@ -252,7 +261,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource
drmSessionManagerProvider.get(mediaItem), drmSessionManagerProvider.get(mediaItem),
loadErrorHandlingPolicy, loadErrorHandlingPolicy,
continueLoadingCheckIntervalBytes, continueLoadingCheckIntervalBytes,
suppressPrepareError, singleTrackId,
singleTrackFormat,
downloadExecutorSupplier); downloadExecutorSupplier);
} }
@ -273,7 +283,19 @@ public final class ProgressiveMediaSource extends BaseMediaSource
private final DrmSessionManager drmSessionManager; private final DrmSessionManager drmSessionManager;
private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy;
private final int continueLoadingCheckIntervalBytes; 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<ReleasableExecutor> downloadExecutorSupplier; @Nullable private final Supplier<ReleasableExecutor> downloadExecutorSupplier;
private boolean timelineIsPlaceholder; private boolean timelineIsPlaceholder;
@ -292,7 +314,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource
DrmSessionManager drmSessionManager, DrmSessionManager drmSessionManager,
LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy,
int continueLoadingCheckIntervalBytes, int continueLoadingCheckIntervalBytes,
boolean suppressPrepareError, int singleTrackId,
@Nullable Format singleTrackFormat,
@Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier) { @Nullable Supplier<ReleasableExecutor> downloadExecutorSupplier) {
this.mediaItem = mediaItem; this.mediaItem = mediaItem;
this.dataSourceFactory = dataSourceFactory; this.dataSourceFactory = dataSourceFactory;
@ -300,7 +323,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource
this.drmSessionManager = drmSessionManager; this.drmSessionManager = drmSessionManager;
this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy;
this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes;
this.suppressPrepareError = suppressPrepareError; this.singleTrackFormat = singleTrackFormat;
this.singleTrackId = singleTrackId;
this.timelineIsPlaceholder = true; this.timelineIsPlaceholder = true;
this.timelineDurationUs = C.TIME_UNSET; this.timelineDurationUs = C.TIME_UNSET;
this.downloadExecutorSupplier = downloadExecutorSupplier; this.downloadExecutorSupplier = downloadExecutorSupplier;
@ -359,7 +383,8 @@ public final class ProgressiveMediaSource extends BaseMediaSource
allocator, allocator,
localConfiguration.customCacheKey, localConfiguration.customCacheKey,
continueLoadingCheckIntervalBytes, continueLoadingCheckIntervalBytes,
suppressPrepareError, singleTrackId,
singleTrackFormat,
Util.msToUs(localConfiguration.imageDurationMs), Util.msToUs(localConfiguration.imageDurationMs),
downloadExecutorSupplier != null ? downloadExecutorSupplier.get() : null); downloadExecutorSupplier != null ? downloadExecutorSupplier.get() : null);
} }

View File

@ -42,6 +42,8 @@ import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -55,6 +57,56 @@ public class SubtitlePlaybackTest {
public ShadowMediaCodecConfig mediaCodecConfig = public ShadowMediaCodecConfig mediaCodecConfig =
ShadowMediaCodecConfig.forAllSupportedMimeTypes(); ShadowMediaCodecConfig.forAllSupportedMimeTypes();
// https://github.com/androidx/media/issues/1721
@Test
public void multipleSideloadedSubtitles_noneSelected_noneLoaded() throws Exception {
Context applicationContext = ApplicationProvider.getApplicationContext();
List<Uri> 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 @Test
public void sideloadedSubtitleLoadingError_playbackContinues_errorReportedToAnalyticsListener() public void sideloadedSubtitleLoadingError_playbackContinues_errorReportedToAnalyticsListener()
throws Exception { throws Exception {
@ -105,9 +157,8 @@ public class SubtitlePlaybackTest {
surface.release(); surface.release();
assertThat(loadErrorEventInfo.get().uri).isEqualTo(notFoundSubtitleUri); assertThat(loadErrorEventInfo.get().uri).isEqualTo(notFoundSubtitleUri);
// Assert the output is the same as playing the video without sideloaded subtitles.
DumpFileAsserts.assertOutput( DumpFileAsserts.assertOutput(
applicationContext, playbackOutput, "playbackdumps/mp4/sample.mp4.dump"); applicationContext, playbackOutput, "playbackdumps/subtitles/sideloaded-error.mp4.dump");
} }
@Test @Test
@ -172,8 +223,6 @@ public class SubtitlePlaybackTest {
.hasMessageThat() .hasMessageThat()
.contains("test subtitle parsing error"); .contains("test subtitle parsing error");
DumpFileAsserts.assertOutput( DumpFileAsserts.assertOutput(
applicationContext, applicationContext, playbackOutput, "playbackdumps/subtitles/sideloaded-error.mp4.dump");
playbackOutput,
"playbackdumps/subtitles/sideloaded-parse-error.mp4.dump");
} }
} }

View File

@ -121,7 +121,8 @@ public final class ProgressiveMediaPeriodTest {
new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE), new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE),
/* customCacheKey= */ null, /* customCacheKey= */ null,
ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES, ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES,
/* suppressPrepareError= */ false, /* singleTrackId= */ 0,
/* singleTrackFormat= */ null,
imageDurationUs, imageDurationUs,
executor != null ? ReleasableExecutor.from(executor, executorReleased) : null); executor != null ? ReleasableExecutor.from(executor, executorReleased) : null);

View File

@ -15,24 +15,39 @@
*/ */
package androidx.media3.exoplayer.source; 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 androidx.media3.test.utils.robolectric.RobolectricUtil.DEFAULT_TIMEOUT_MS;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.MILLISECONDS; 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.annotation.Nullable;
import androidx.media3.common.Format;
import androidx.media3.common.MediaItem; import androidx.media3.common.MediaItem;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.Timeline; import androidx.media3.common.Timeline;
import androidx.media3.common.util.ConditionVariable;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DefaultDataSource; 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.analytics.PlayerId;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
import androidx.media3.exoplayer.trackselection.FixedTrackSelection;
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy; import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy;
import androidx.media3.test.utils.MediaSourceTestRunner; import androidx.media3.test.utils.MediaSourceTestRunner;
import androidx.media3.test.utils.TestUtil; import androidx.media3.test.utils.TestUtil;
import androidx.media3.test.utils.robolectric.RobolectricUtil; import androidx.media3.test.utils.robolectric.RobolectricUtil;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.util.concurrent.ListenableFuture;
import java.io.IOException; import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test; import org.junit.Test;
@ -119,14 +134,91 @@ public class ProgressiveMediaSourceTest {
} }
@Test @Test
public void maybeThrowPrepareError_withSuppressPrepareError_doesNotThrow() throws Exception { public void lazyLoading_preparationCompletesWithoutLoadingData_loadsDataWhenTrackSelected()
throws Exception {
Set<Uri> 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<Boolean> 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 = ProgressiveMediaSource mediaSource =
new ProgressiveMediaSource.Factory( new ProgressiveMediaSource.Factory(
new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())) new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext()))
// Disable retries, so the first error is marked fatal. // Disable retries, so the first error is marked fatal.
.setLoadErrorHandlingPolicy( .setLoadErrorHandlingPolicy(
new DefaultLoadErrorHandlingPolicy(/* minimumLoadableRetryCount= */ 0)) new DefaultLoadErrorHandlingPolicy(/* minimumLoadableRetryCount= */ 0))
.setSuppressPrepareError(true) .enableLazyLoadingWithSingleTrack(
/* trackId= */ 42, new Format.Builder().setId("format ID").build())
.createMediaSource(MediaItem.fromUri("file:///not/found")); .createMediaSource(MediaItem.fromUri("file:///not/found"));
MediaSourceTestRunner mediaSourceTestRunner = new MediaSourceTestRunner(mediaSource); MediaSourceTestRunner mediaSourceTestRunner = new MediaSourceTestRunner(mediaSource);
@ -154,27 +246,19 @@ public class ProgressiveMediaSourceTest {
timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0)); timeline.getUidOfPeriod(/* periodIndex= */ 0), /* windowSequenceNumber= */ 0));
CountDownLatch preparedLatch = CountDownLatch preparedLatch =
mediaSourceTestRunner.preparePeriod(mediaPeriod, /* positionUs= */ 0); 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<Throwable> 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(); assertThat(preparedLatch.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue();
ListenableFuture<Boolean> 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.releasePeriod(mediaPeriod);
mediaSourceTestRunner.releaseSource(); mediaSourceTestRunner.releaseSource();
mediaSourceTestRunner.release(); mediaSourceTestRunner.release();
@ -185,4 +269,14 @@ public class ProgressiveMediaSourceTest {
new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext())) new DefaultDataSource.Factory(ApplicationProvider.getApplicationContext()))
.createMediaSource(mediaItem); .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);
}
} }

View File

@ -20,6 +20,7 @@ import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static java.lang.annotation.ElementType.TYPE_USE; import static java.lang.annotation.ElementType.TYPE_USE;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.MimeTypes; 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. */ /** Generic extractor for extracting subtitles from various subtitle formats. */
@UnstableApi @UnstableApi
public class SubtitleExtractor implements Extractor { public class SubtitleExtractor implements Extractor {
/** The ID of the single track emitted by this extractor. */
public static final int TRACK_ID = 0;
@Documented @Documented
@Retention(RetentionPolicy.SOURCE) @Retention(RetentionPolicy.SOURCE)
@Target(TYPE_USE) @Target(TYPE_USE)
@ -83,7 +88,7 @@ public class SubtitleExtractor implements Extractor {
private final SubtitleParser subtitleParser; private final SubtitleParser subtitleParser;
private final CueEncoder cueEncoder; private final CueEncoder cueEncoder;
private final Format format; @Nullable private final Format format;
private final List<Sample> samples; private final List<Sample> samples;
private final ParsableByteArray scratchSampleArray; 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 * @param subtitleParser The parser used for parsing the subtitle data. The extractor will reset
* the parser in {@link SubtitleExtractor#release()}. * 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; this.subtitleParser = subtitleParser;
cueEncoder = new CueEncoder(); cueEncoder = new CueEncoder();
subtitleData = Util.EMPTY_BYTE_ARRAY; subtitleData = Util.EMPTY_BYTE_ARRAY;
scratchSampleArray = new ParsableByteArray(); scratchSampleArray = new ParsableByteArray();
// TODO: b/376693592 - Simplify this by taking the post-transformation Format as a parameter
// instead.
this.format = this.format =
format format != null
? format
.buildUpon() .buildUpon()
.setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES) .setSampleMimeType(MimeTypes.APPLICATION_MEDIA3_CUES)
.setCodecs(format.sampleMimeType) .setCodecs(format.sampleMimeType)
.setCueReplacementBehavior(subtitleParser.getCueReplacementBehavior()) .setCueReplacementBehavior(subtitleParser.getCueReplacementBehavior())
.build(); .build()
: null;
samples = new ArrayList<>(); samples = new ArrayList<>();
state = STATE_CREATED; state = STATE_CREATED;
timestamps = Util.EMPTY_LONG_ARRAY; timestamps = Util.EMPTY_LONG_ARRAY;
@ -130,7 +141,8 @@ public class SubtitleExtractor implements Extractor {
@Override @Override
public void init(ExtractorOutput output) { public void init(ExtractorOutput output) {
checkState(state == STATE_CREATED); checkState(state == STATE_CREATED);
trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_TEXT); trackOutput = output.track(TRACK_ID, C.TRACK_TYPE_TEXT);
if (format != null) {
trackOutput.format(format); trackOutput.format(format);
output.endTracks(); output.endTracks();
output.seekMap( output.seekMap(
@ -138,6 +150,7 @@ public class SubtitleExtractor implements Extractor {
/* positions= */ new long[] {0}, /* positions= */ new long[] {0},
/* timesUs= */ new long[] {0}, /* timesUs= */ new long[] {0},
/* durationUs= */ C.TIME_UNSET)); /* durationUs= */ C.TIME_UNSET));
}
state = STATE_INITIALIZED; state = STATE_INITIALIZED;
} }

View File

@ -42,13 +42,17 @@ import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller;
import androidx.media3.exoplayer.source.MediaSourceEventListener; import androidx.media3.exoplayer.source.MediaSourceEventListener;
import androidx.media3.exoplayer.upstream.Allocator; import androidx.media3.exoplayer.upstream.Allocator;
import androidx.media3.exoplayer.upstream.DefaultAllocator; 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.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.LinkedBlockingDeque;
import org.checkerframework.dataflow.qual.SideEffectFree; import org.checkerframework.dataflow.qual.SideEffectFree;
@ -93,26 +97,35 @@ public class MediaSourceTestRunner {
* @param runnable The {@link Runnable} to run. * @param runnable The {@link Runnable} to run.
*/ */
public void runOnPlaybackThread(final Runnable runnable) { public void runOnPlaybackThread(final Runnable runnable) {
Throwable[] throwable = new Throwable[1]; ListenableFuture<Void> result =
CountDownLatch finishedLatch = new CountDownLatch(1); 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 <T> ListenableFuture<T> asyncRunOnPlaybackThread(Callable<T> callable) {
SettableFuture<T> result = SettableFuture.create();
playbackHandler.post( playbackHandler.post(
() -> { () -> {
try { try {
runnable.run(); result.set(callable.call());
} catch (Throwable e) { } catch (Throwable e) {
throwable[0] = e; result.setException(e);
} finally {
finishedLatch.countDown();
} }
}); });
try { return result;
assertThat(finishedLatch.await(TIMEOUT_MS, MILLISECONDS)).isTrue();
} catch (InterruptedException e) {
Util.sneakyThrow(e);
}
if (throwable[0] != null) {
Util.sneakyThrow(throwable[0]);
}
} }
/** /**