diff --git a/RELEASENOTES.md b/RELEASENOTES.md index ab798aa4c92..2a8d548ed20 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -33,6 +33,13 @@ enabled ([#3286](https://github.com/androidx/media/issues/3286)). * Remove experimental `DefaultMediaCodecAdapterFactory.setAsyncCryptoSynchronizationEnabled(boolean)`. + * Fix an issue where returning from a client-side inserted ad to a content + period that resumes on a sync sample could reset the renderers + unnecessarily. `ClippingMediaPeriod` no longer reports + `SampleStream.FLAG_HAS_PREROLL` for clips that only set an end position, + where the wrapped period's own report is exact. Only takes effect when + per-stream media progression is enabled + ([#3371](https://github.com/androidx/media/issues/3371)). * CompositionPlayer: * Support configuring the frame rate of video frame aggregation via `Composition.Builder.setVideoFrameAggregationParameters` for playback diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java index 466ec04620d..b998e606749 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaPeriod.java @@ -352,17 +352,17 @@ private SeekParameters clipSeekParameters(long positionUs, SeekParameters seekPa } private static boolean shouldKeepInitialDiscontinuity( - long startUs, + long enablePositionUs, long requestedPositionUs, @NullableType ExoTrackSelection[] selections, int streamIndex) { - // If the source adjusted the start position to be before the requested position, we need to + // If the source adjusted the enable position to be before the requested position, we need to // report a discontinuity to ensure renderers decode-only the samples before the requested start // position. - if (startUs < requestedPositionUs) { + if (enablePositionUs < requestedPositionUs) { return true; } - // If the clipping start position is non-zero, the clipping sample streams will adjust + // If the enable position is non-zero, the clipping sample streams will adjust // timestamps on buffers they read from the unclipped sample streams. These adjusted buffer // timestamps can be negative, because sample streams provide buffers starting at a key-frame, // which may be before the clipping start point. When the renderer reads a buffer with a @@ -371,7 +371,7 @@ private static boolean shouldKeepInitialDiscontinuity( // discontinuity which resets the renderers before they read the clipping sample stream. // However, for tracks where all samples are sync samples, we assume they have random access // seek behaviour and do not need an initial discontinuity to reset the renderer. - if (startUs != 0) { + if (enablePositionUs != 0) { if (selections[streamIndex] != null) { Format selectedFormat = selections[streamIndex].getSelectedFormat(); return !MimeTypes.allSamplesAreSyncSamples( @@ -471,7 +471,12 @@ public int skipData(long positionUs) { if (endUs != C.TIME_END_OF_SOURCE) { flags |= FLAG_STRICT_DURATION; } - if (hasPreroll) { + // A non-zero clipping start introduces preroll the wrapped period cannot know about, because + // sample streams provide buffers starting at a key-frame that may precede the clipping start + // point. With a zero clipping start the samples delivered are exactly the samples the wrapped + // period delivers, so its own report is exact and must not be overwritten with the + // conservative assumption made by the enable-position heuristic. + if (startUs != 0 && hasPreroll) { flags &= ~FLAG_MAYBE_HAS_PREROLL; flags |= FLAG_HAS_PREROLL; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerAdTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerAdTest.java index 03d103dad49..09f994b48e3 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerAdTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerAdTest.java @@ -25,6 +25,7 @@ import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.advance; import static androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.mockito.AdditionalMatchers.not; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; @@ -42,6 +43,7 @@ import androidx.media3.common.AdPlaybackState; import androidx.media3.common.C; import androidx.media3.common.Flags; +import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; @@ -51,6 +53,7 @@ import androidx.media3.common.Timeline; import androidx.media3.common.Timeline.Period; import androidx.media3.common.Timeline.Window; +import androidx.media3.common.util.HandlerWrapper; import androidx.media3.common.util.SystemClock; import androidx.media3.common.util.Util; import androidx.media3.datasource.DataSpec; @@ -58,19 +61,29 @@ import androidx.media3.exoplayer.ExoPlayer.PreloadConfiguration; import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionManager; +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider; import androidx.media3.exoplayer.source.MediaPeriod; +import androidx.media3.exoplayer.source.MediaSource; +import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MediaSourceEventListener; +import androidx.media3.exoplayer.source.SampleStream; import androidx.media3.exoplayer.source.TrackGroupArray; import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource; import androidx.media3.exoplayer.upstream.Allocator; +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy; +import androidx.media3.exoplayer.video.VideoRendererEventListener; import androidx.media3.test.utils.ActionSchedule; import androidx.media3.test.utils.ActionSchedule.PlayerRunnable; +import androidx.media3.test.utils.BindFlag; import androidx.media3.test.utils.ExoPlayerTestRunner; import androidx.media3.test.utils.FakeAdsLoader; import androidx.media3.test.utils.FakeMediaPeriod; +import androidx.media3.test.utils.FakeMediaPeriod.TrackDataFactory; import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeMediaSourceFactory; +import androidx.media3.test.utils.FakeSampleStream; +import androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.FakeVideoRenderer; @@ -112,11 +125,13 @@ public class ExoPlayerAdTest { */ private static final int TIMEOUT_MS = 10_000; - @Parameters(name = "preload={0}") + @Parameters(name = "preload={0} perStream={2}") public static ImmutableList params() { return ImmutableList.of( - new Object[] {false, new PreloadConfiguration(C.TIME_UNSET)}, - new Object[] {true, new PreloadConfiguration(5_000_000L)}); + new Object[] {false, new PreloadConfiguration(C.TIME_UNSET), false}, + new Object[] {false, new PreloadConfiguration(C.TIME_UNSET), true}, + new Object[] {true, new PreloadConfiguration(5_000_000L), false}, + new Object[] {true, new PreloadConfiguration(5_000_000L), true}); } @Rule @@ -132,6 +147,11 @@ public static ImmutableList params() { @Parameter(1) public ExoPlayer.PreloadConfiguration preloadConfiguration; + // TODO: b/510217604 - Remove parameterization. + @Parameter(2) + @BindFlag(Flags.FLAG_PER_STREAM_MEDIA_PROGRESSION) + public boolean perStreamMediaProgressionEnabled; + private Context context; @Before @@ -2428,6 +2448,349 @@ public void newServerSideInsertedAdAtPlaybackPosition_keepsRenderersEnabled() th .isEqualTo(2); } + /** + * Returns to content from mid-rolls that are placed exactly on a content segment boundary, so + * that the content periods resume at a sync sample and there is no preroll to skip. + * + *

This is the case reported in issue + * #3371, whose stream uses {@code X-SNAP="IN"}. Since no content period delivers preroll, + * nothing needs resetting and the renderer must simply stay enabled for the whole playback. + */ + @Test + public void playAds_returningFromSegmentAlignedMidRoll_keepsRenderersEnabled() throws Exception { + PrerollMeasurement measurement = + playAdsWithMidRolls( + /* firstAdGroupTimeUs= */ 20_000_000L, /* secondAdGroupTimeUs= */ 40_000_000L); + + assertWithMessage("expected the content source to deliver no preroll") + .that(measurement.prerollSamplesSkipped + measurement.prerollSamplesRendered) + .isEqualTo(0); + // The renderer only avoids the reset where the initial discontinuity is decided from the + // stream's reported preroll. The legacy path decides it from a heuristic on the enable + // position, which cannot tell a segment-aligned resume from a mid-segment one, so it still + // resets once. See ClippingMediaPeriod.shouldKeepInitialDiscontinuity. + int expectedEnabledCount = perStreamMediaProgressionEnabled ? 1 : 2; + assertWithMessage("[renderer enable count, preroll samples rendered]") + .that(ImmutableList.of(measurement.enabledCount, measurement.prerollSamplesRendered)) + .isEqualTo(ImmutableList.of(expectedEnabledCount, 0)); + } + + /** + * Returns to content from mid-rolls that are placed mid-segment, so that every content period + * resumes part-way through a segment and really does deliver preroll. + * + *

No preroll sample may be rendered. Content periods that carry preroll still need their + * renderer reset, because {@code MediaCodecRenderer} decides decode-only purely from {@code + * getLastResetPositionUs()}, which only {@code Renderer.enable()}/{@code resetPosition()} update + * — never {@code replaceStream()}. The renderer enable count is asserted alongside the rendered + * count so that a change trading one for the other is visible rather than looking like a fix. + * + *

The first content period is wrapped in a {@link ClippingMediaPeriod} and so is reset and + * skips its preroll correctly. The last content period is not wrapped, because it has no + * following ad group, and nothing turns its preroll into a discontinuity — so gate-closed it + * renders 5 s of already-played content, half a segment, a separate, pre-existing defect that + * this change does not address; the 50 rendered samples asserted below pin it. Gate-open the + * reading-period transition already honours the preroll flags the stream reports for this + * unclipped period too, so the defect is absent, at the cost of a third renderer enable. + * + *

The assertion pins the current behaviour rather than the desired behaviour so that the + * defect is visible and any change to it is deliberate. Gate-closed the desired value is {@code + * [2, 0]}, and reaching it needs the reading-period transition to honour the preroll flags that + * streams now report, for periods that are not clipped — confirmed gate-open, where those flags + * are honoured and the rendered count drops to 0. + */ + @Test + public void playAds_returningFromMidSegmentMidRoll_skipsPrerollOfClippedPeriodsOnly() + throws Exception { + PrerollMeasurement measurement = + playAdsWithMidRolls( + /* firstAdGroupTimeUs= */ 25_000_000L, /* secondAdGroupTimeUs= */ 45_000_000L); + + assertWithMessage("expected the content source to deliver preroll samples") + .that(measurement.prerollSamplesSkipped + measurement.prerollSamplesRendered) + .isGreaterThan(0); + // Gate-open, the preroll flags the streams report are honoured for the unclipped last content + // period too, so its preroll becomes decode-only and none of it is rendered -- at the cost of + // one additional renderer enable. These content periods genuinely have preroll and report it + // honestly, so stopping the fabrication (Change A) changes nothing for them: the numbers here + // are unaffected by that change. + int expectedEnabledCount = perStreamMediaProgressionEnabled ? 3 : 2; + int expectedPrerollSamplesRendered = perStreamMediaProgressionEnabled ? 0 : 50; + assertWithMessage("[renderer enable count, preroll samples rendered]") + .that(ImmutableList.of(measurement.enabledCount, measurement.prerollSamplesRendered)) + .isEqualTo(ImmutableList.of(expectedEnabledCount, expectedPrerollSamplesRendered)); + } + + /** The result of one {@link #playAdsWithMidRolls} playback. */ + private static final class PrerollMeasurement { + public final int enabledCount; + public final int prerollSamplesSkipped; + public final int prerollSamplesRendered; + + private PrerollMeasurement( + int enabledCount, int prerollSamplesSkipped, int prerollSamplesRendered) { + this.enabledCount = enabledCount; + this.prerollSamplesSkipped = prerollSamplesSkipped; + this.prerollSamplesRendered = prerollSamplesRendered; + } + } + + /** + * Plays content with two mid-roll ad groups at the given positions and measures how the video + * renderer was driven across the ad-to-content transitions. + * + *

Playback structure is 'A a B b C', where A, B and C are content periods and a and b are ads. + * {@link AdsMediaSource} wraps A and B (the content periods that have a following ad group) in a + * {@link ClippingMediaPeriod}, but not C, so the two shapes described in the analysis of issue + * #3371 are both exercised in a single playback. + * + *

The content source models preroll explicitly: a plain {@link FakeMediaSource} cannot express + * it, because {@link FakeSampleStream#writeData} only ever writes samples at or after the + * requested position. + */ + private PrerollMeasurement playAdsWithMidRolls(long firstAdGroupTimeUs, long secondAdGroupTimeUs) + throws Exception { + AtomicReference videoRenderer = new AtomicReference<>(); + RenderersFactory renderersFactory = + (handler, videoListener, audioListener, textOutput, metadataOutput) -> { + videoRenderer.set( + new PrerollTrackingVideoRenderer( + SystemClock.DEFAULT.createHandler(handler.getLooper(), /* callback= */ null), + videoListener)); + return new Renderer[] {videoRenderer.get()}; + }; + Timeline primaryContentTimeline = + new FakeTimeline( + new TimelineWindowDefinition.Builder() + .setWindowPositionInFirstPeriodUs(0L) + .setDurationUs(60_000_000L) + .build()); + // Two mid-rolls, placed so that the content period between them has a next ad group while the + // content period after the last ad group has none. + AdPlaybackState adPlaybackState = + new AdPlaybackState( + "adsId", /* adGroupTimesUs...= */ firstAdGroupTimeUs, secondAdGroupTimeUs) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 5_000_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/ad_0_0")) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 1, 5_000_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("http://example.com/ad_1_0")); + // The ads use the same format as the content, so that the renderer can be kept enabled across + // the content/ad transitions. + MediaSource.Factory adMediaSourceFactory = + new MediaSource.Factory() { + @Override + public MediaSource.Factory setDrmSessionManagerProvider( + DrmSessionManagerProvider drmSessionManagerProvider) { + throw new UnsupportedOperationException(); + } + + @Override + public MediaSource.Factory setLoadErrorHandlingPolicy( + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + throw new UnsupportedOperationException(); + } + + @Override + public @C.ContentType int[] getSupportedTypes() { + return new int[] {C.CONTENT_TYPE_OTHER}; + } + + @Override + public MediaSource createMediaSource(MediaItem mediaItem) { + return new FakeMediaSource( + new FakeTimeline( + new TimelineWindowDefinition.Builder() + .setMediaItem(mediaItem) + .setDurationUs(5_000_000L) + .build()), + ExoPlayerTestRunner.VIDEO_FORMAT); + } + }; + FakeAdsLoader fakeAdsLoader = new FakeAdsLoader(); + AdsMediaSource adsMediaSource = + new AdsMediaSource( + new PrerollEmittingMediaSource( + primaryContentTimeline, + TrackDataFactory.samplesWithRateDurationAndKeyframeInterval( + /* initialSampleTimeUs= */ 0, + /* sampleRate= */ CONTENT_SAMPLES_PER_SECOND, + /* durationUs= */ 60_000_000L, + /* keyFrameInterval= */ CONTENT_SAMPLES_PER_SEGMENT), + ExoPlayerTestRunner.VIDEO_FORMAT), + new DataSpec(Uri.EMPTY), + "adsId", + /* adMediaSourceFactory= */ adMediaSourceFactory, + fakeAdsLoader, + /* adViewProvider= */ () -> null, + /* useLazyContentSourcePreparation= */ true, + /* useAdMediaSourceClipping= */ true); + ExoPlayer player = + parameterizeTestExoPlayerBuilder( + new TestExoPlayerBuilder(context).setRenderersFactory(renderersFactory)) + .build(); + player.setMediaSource(adsMediaSource); + player.prepare(); + advance(player) + .untilBackgroundThreadCondition( + (Supplier) () -> fakeAdsLoader.eventListeners.get("adsId") != null); + fakeAdsLoader.eventListeners.get("adsId").onAdPlaybackState(adPlaybackState); + + player.play(); + advance(player).untilState(Player.STATE_ENDED); + int enabledCount = videoRenderer.get().enabledCount; + int prerollSamplesSkipped = videoRenderer.get().prerollSamplesSkipped; + int prerollSamplesRendered = videoRenderer.get().prerollSamplesRendered; + player.release(); + + return new PrerollMeasurement(enabledCount, prerollSamplesSkipped, prerollSamplesRendered); + } + + /** The number of content samples per second produced by the content source. */ + private static final float CONTENT_SAMPLES_PER_SECOND = 10; + + /** The duration of a content "segment", i.e. the spacing between content sync samples. */ + private static final long CONTENT_SEGMENT_DURATION_US = 10_000_000L; + + private static final int CONTENT_SAMPLES_PER_SEGMENT = + (int) (CONTENT_SAMPLES_PER_SECOND * CONTENT_SEGMENT_DURATION_US / C.MICROS_PER_SECOND); + + /** Returns the start of the segment containing {@code positionUs}. */ + private static long contentSegmentStartUs(long positionUs) { + return (positionUs / CONTENT_SEGMENT_DURATION_US) * CONTENT_SEGMENT_DURATION_US; + } + + /** + * A {@link FakeMediaSource} whose periods deliver samples from the start of the segment + * containing the period's start position, rather than from exactly that position. + * + *

This models what a real adaptive source does: HLS and DASH load whole segments/chunks, so a + * period prepared part-way through a segment delivers the samples between the preceding sync + * sample and the requested position as preroll, and relies on the renderer to decode them without + * rendering them. A plain {@link FakeMediaSource} cannot express this, because {@link + * FakeSampleStream#writeData} only writes samples at or after the requested position. + */ + private static final class PrerollEmittingMediaSource extends FakeMediaSource { + + private final TrackDataFactory trackDataFactory; + + public PrerollEmittingMediaSource( + Timeline timeline, TrackDataFactory trackDataFactory, Format... formats) { + super(timeline, formats); + this.trackDataFactory = trackDataFactory; + } + + @Override + protected MediaPeriod createMediaPeriod( + MediaPeriodId id, + TrackGroupArray trackGroupArray, + Allocator allocator, + MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + @Nullable TransferListener transferListener) { + return new FakeMediaPeriod( + trackGroupArray, + allocator, + trackDataFactory, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + /* deferOnPrepared= */ false) { + + private long preparePositionUs; + + @Override + public synchronized void prepare(MediaPeriod.Callback callback, long positionUs) { + preparePositionUs = positionUs; + super.prepare(callback, positionUs); + } + + @Override + protected FakeSampleStream createSampleStream( + Allocator allocator, + @Nullable MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + Format initialFormat, + List fakeSampleStreamItems) { + long segmentStartUs = contentSegmentStartUs(preparePositionUs); + boolean hasPreroll = segmentStartUs < preparePositionUs; + return new FakeSampleStream( + allocator, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + initialFormat, + fakeSampleStreamItems) { + @Override + public void writeData(long startPositionUs) { + // Deliver from the start of the containing segment, not from startPositionUs. + super.writeData(segmentStartUs); + } + + @Override + public @SampleStream.Flags int getFlags() { + // Report preroll exactly, as a real chunk-loading source does. + return super.getFlags() | (hasPreroll ? SampleStream.FLAG_HAS_PREROLL : 0); + } + }; + } + }; + } + } + + /** + * A {@link FakeVideoRenderer} that counts samples belonging to the preroll of the stream it is + * currently reading, split by whether the renderer would have treated them as decode-only. + * + *

The decode-only condition mirrors {@code MediaCodecRenderer}: a buffer is decode-only if its + * timestamp is before {@link #getLastResetPositionUs()}. + */ + private static final class PrerollTrackingVideoRenderer extends FakeVideoRenderer { + + /** Preroll samples correctly treated as decode-only. */ + public int prerollSamplesSkipped; + + /** Preroll samples that would have been rendered, i.e. dropped or shown as stale content. */ + public int prerollSamplesRendered; + + private long streamStartPositionUs; + + public PrerollTrackingVideoRenderer( + HandlerWrapper handler, VideoRendererEventListener eventListener) { + super(handler, eventListener); + } + + @Override + protected void onStreamChanged( + Format[] formats, long startPositionUs, long offsetUs, MediaPeriodId mediaPeriodId) + throws ExoPlaybackException { + super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId); + streamStartPositionUs = startPositionUs; + } + + @Override + protected boolean shouldProcessBuffer(long bufferTimeUs, long playbackPositionUs) { + boolean shouldProcess = super.shouldProcessBuffer(bufferTimeUs, playbackPositionUs); + if (shouldProcess && bufferTimeUs < streamStartPositionUs) { + if (bufferTimeUs < getLastResetPositionUs()) { + prerollSamplesSkipped++; + } else { + prerollSamplesRendered++; + } + } + return shouldProcess; + } + } + @Test public void playAds_midRollsInMultiPeriodContent_playedSuccessfully() throws PlaybackException, TimeoutException { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java index b31051d09ae..99aef119169 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaPeriodTest.java @@ -16,6 +16,7 @@ package androidx.media3.exoplayer.source; import static androidx.media3.exoplayer.source.SampleStream.FLAG_HAS_PREROLL; +import static androidx.media3.exoplayer.source.SampleStream.FLAG_MAYBE_HAS_PREROLL; import static androidx.media3.exoplayer.source.SampleStream.FLAG_STRICT_DURATION; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.END_OF_STREAM_ITEM; import static androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem.oneByteSample; @@ -157,7 +158,9 @@ public void readDiscontinuity_prepareFromNonZero_returnsPreparePosition() throws assertThat(sampleStreams[0]).isNotNull(); assertThat(sampleStreams[0].getFlags()).isEqualTo(FLAG_STRICT_DURATION); - assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_HAS_PREROLL | FLAG_STRICT_DURATION); + // The clip only sets an end position, so the wrapped period's report of its own preroll is + // passed through rather than being replaced by the enable-position heuristic. + assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_STRICT_DURATION); assertThat(discontinuityPositionUs).isEqualTo(250); } @@ -264,7 +267,9 @@ public void readDiscontinuity_repeatedCalls_returnsUnset() throws Exception { long discontinuityPositionUs = clippingMediaPeriod.readDiscontinuity(); assertThat(sampleStreams[0].getFlags()).isEqualTo(FLAG_STRICT_DURATION); - assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_HAS_PREROLL | FLAG_STRICT_DURATION); + // The clip only sets an end position, so the wrapped period's report of its own preroll is + // passed through rather than being replaced by the enable-position heuristic. + assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_STRICT_DURATION); assertThat(discontinuityPositionUs).isEqualTo(C.TIME_UNSET); } @@ -305,7 +310,9 @@ public void readDiscontinuity_afterInitialSeek_returnsUnset() throws Exception { clippingMediaPeriod.seekToUs(400); long discontinuityPositionUs = clippingMediaPeriod.readDiscontinuity(); - assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_STRICT_DURATION | FLAG_HAS_PREROLL); + // The clip only sets an end position, so the wrapped period's report of its own preroll is + // passed through rather than being replaced by the enable-position heuristic. + assertThat(sampleStreams[1].getFlags()).isEqualTo(FLAG_STRICT_DURATION); assertThat(discontinuityPositionUs).isEqualTo(C.TIME_UNSET); } @@ -509,4 +516,116 @@ public int getFlags() { assertThat(sampleStreams[0].getFlags()).isEqualTo(SampleStream.FLAG_STRICT_DURATION); } + + @Test + public void selectTracks_withEndClippingAndChildStreamMaybeHasPreroll_passesThroughChildFlags() + throws Exception { + TrackGroupArray trackGroups = new TrackGroupArray(VIDEO_TRACK_GROUP); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroups, + new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), + /* trackDataFactory= */ (format, mediaPeriodId) -> ImmutableList.of(), + new MediaSourceEventListener.EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + new MediaSource.MediaPeriodId(/* periodUid= */ new Object())), + DrmSessionManager.DRM_UNSUPPORTED, + new DrmSessionEventListener.EventDispatcher(), + /* deferOnPrepared= */ false) { + @Override + protected FakeSampleStream createSampleStream( + Allocator allocator, + @Nullable MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + Format initialFormat, + List fakeSampleStreamItems) { + return new FakeSampleStream( + allocator, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + initialFormat, + fakeSampleStreamItems) { + @Override + public int getFlags() { + return FLAG_MAYBE_HAS_PREROLL; + } + }; + } + }; + + ClippingMediaPeriod clippingMediaPeriod = + new ClippingMediaPeriod( + mediaPeriod, + /* enableInitialDiscontinuity= */ true, + /* startUs= */ 0, + /* endUs= */ 500); + + SampleStream[] sampleStreams = + prepareMediaPeriodAndSelectTracks( + clippingMediaPeriod, /* preparePositionUs= */ 250, trackGroups); + + // The clip only sets an end position, so it adds no preroll of its own and the child's + // FLAG_MAYBE_HAS_PREROLL must survive. The flag tells the player to defer the reading period + // transition until the child resolves its preroll, instead of resetting the renderer now. + assertThat(sampleStreams[0].getFlags()) + .isEqualTo(FLAG_MAYBE_HAS_PREROLL | FLAG_STRICT_DURATION); + } + + @Test + public void selectTracks_withStartAndEndClippingAndChildStreamMaybeHasPreroll_setsFlagHasPreroll() + throws Exception { + TrackGroupArray trackGroups = new TrackGroupArray(VIDEO_TRACK_GROUP); + FakeMediaPeriod mediaPeriod = + new FakeMediaPeriod( + trackGroups, + new DefaultAllocator(/* trimOnReset= */ true, /* individualAllocationSize= */ 1024), + /* trackDataFactory= */ (format, mediaPeriodId) -> ImmutableList.of(), + new MediaSourceEventListener.EventDispatcher() + .withParameters( + /* windowIndex= */ 0, + new MediaSource.MediaPeriodId(/* periodUid= */ new Object())), + DrmSessionManager.DRM_UNSUPPORTED, + new DrmSessionEventListener.EventDispatcher(), + /* deferOnPrepared= */ false) { + @Override + protected FakeSampleStream createSampleStream( + Allocator allocator, + @Nullable MediaSourceEventListener.EventDispatcher mediaSourceEventDispatcher, + DrmSessionManager drmSessionManager, + DrmSessionEventListener.EventDispatcher drmEventDispatcher, + Format initialFormat, + List fakeSampleStreamItems) { + return new FakeSampleStream( + allocator, + mediaSourceEventDispatcher, + drmSessionManager, + drmEventDispatcher, + initialFormat, + fakeSampleStreamItems) { + @Override + public int getFlags() { + return FLAG_MAYBE_HAS_PREROLL; + } + }; + } + }; + + ClippingMediaPeriod clippingMediaPeriod = + new ClippingMediaPeriod( + mediaPeriod, + /* enableInitialDiscontinuity= */ true, + /* startUs= */ 250, + /* endUs= */ 500); + + SampleStream[] sampleStreams = + prepareMediaPeriodAndSelectTracks( + clippingMediaPeriod, /* preparePositionUs= */ 250, trackGroups); + + // The clip sets a non-zero start position, so it introduces preroll and overrides the child's + // FLAG_MAYBE_HAS_PREROLL with FLAG_HAS_PREROLL. + assertThat(sampleStreams[0].getFlags()).isEqualTo(FLAG_HAS_PREROLL | FLAG_STRICT_DURATION); + } }