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`
([#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:

View File

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

View File

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

View File

@ -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<ReleasableExecutor> 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.
*
* <p>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.
* <p>This must only be set if the source is guaranteed to emit a single track with the provided
* ID and format.
*
* <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
/* 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<ReleasableExecutor> 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<ReleasableExecutor> 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);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Void> 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 <T> ListenableFuture<T> asyncRunOnPlaybackThread(Callable<T> callable) {
SettableFuture<T> 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;
}
/**