mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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:
parent
0037388660
commit
18d156fe3c
@ -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:
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Loading…
x
Reference in New Issue
Block a user