diff --git a/RELEASENOTES.md b/RELEASENOTES.md index c8dee220aa..f24b17bddf 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -4,6 +4,8 @@ * Common Library: * ExoPlayer: + * Add option to `ClippingMediaSource` to allow clipping in unseekable + media. * Transformer: * Track Selection: * Extractors: 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 a887b7d043..912d394024 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 @@ -15,6 +15,9 @@ */ package androidx.media3.exoplayer.source; +import static java.lang.Math.max; +import static java.lang.Math.min; + import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Format; @@ -132,18 +135,16 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb sampleStreams[i] = (ClippingSampleStream) streams[i]; childStreams[i] = sampleStreams[i] != null ? sampleStreams[i].childStream : null; } - long enablePositionUs = + long realEnablePositionUs = mediaPeriod.selectTracks( selections, mayRetainStreamFlags, childStreams, streamResetFlags, positionUs); + long correctedEnablePositionUs = + enforceClippingRange(realEnablePositionUs, /* minPositionUs= */ positionUs, endUs); pendingInitialDiscontinuityPositionUs = isPendingInitialDiscontinuity() - && shouldKeepInitialDiscontinuity(enablePositionUs, selections) - ? enablePositionUs + && shouldKeepInitialDiscontinuity(realEnablePositionUs, positionUs, selections) + ? correctedEnablePositionUs : C.TIME_UNSET; - Assertions.checkState( - enablePositionUs == positionUs - || (enablePositionUs >= startUs - && (endUs == C.TIME_END_OF_SOURCE || enablePositionUs <= endUs))); for (int i = 0; i < streams.length; i++) { if (childStreams[i] == null) { sampleStreams[i] = null; @@ -152,7 +153,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb } streams[i] = sampleStreams[i]; } - return enablePositionUs; + return correctedEnablePositionUs; } @Override @@ -178,9 +179,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb if (discontinuityUs == C.TIME_UNSET) { return C.TIME_UNSET; } - Assertions.checkState(discontinuityUs >= startUs); - Assertions.checkState(endUs == C.TIME_END_OF_SOURCE || discontinuityUs <= endUs); - return discontinuityUs; + return enforceClippingRange(discontinuityUs, startUs, endUs); } @Override @@ -201,11 +200,7 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb sampleStream.clearSentEos(); } } - long seekUs = mediaPeriod.seekToUs(positionUs); - Assertions.checkState( - seekUs == positionUs - || (seekUs >= startUs && (endUs == C.TIME_END_OF_SOURCE || seekUs <= endUs))); - return seekUs; + return enforceClippingRange(mediaPeriod.seekToUs(positionUs), startUs, endUs); } @Override @@ -275,7 +270,13 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb } private static boolean shouldKeepInitialDiscontinuity( - long startUs, @NullableType ExoTrackSelection[] selections) { + long startUs, long requestedPositionUs, @NullableType ExoTrackSelection[] selections) { + // If the source adjusted the start 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) { + return true; + } // If the clipping start 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, @@ -299,6 +300,15 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb return false; } + private static long enforceClippingRange( + long positionUs, long minPositionUs, long maxPositionUs) { + positionUs = max(positionUs, minPositionUs); + if (maxPositionUs != C.TIME_END_OF_SOURCE) { + positionUs = min(positionUs, maxPositionUs); + } + return positionUs; + } + /** Wraps a {@link SampleStream} and clips its samples. */ private final class ClippingSampleStream implements SampleStream { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java index 3955426006..1fcd3debe3 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ClippingMediaSource.java @@ -57,6 +57,7 @@ public final class ClippingMediaSource extends WrappingMediaSource { private boolean enableInitialDiscontinuity; private boolean allowDynamicClippingUpdates; private boolean relativeToDefaultPosition; + private boolean allowUnseekableMedia; private boolean buildCalled; /** @@ -195,6 +196,25 @@ public final class ClippingMediaSource extends WrappingMediaSource { return this; } + /** + * Sets whether clipping to a non-zero start position in unseekable media is allowed. + * + *

Note that this is inefficient because the player needs to read and decode all samples from + * the beginning of the file and it should only be used if the seek start position is small and + * the entire data before the start position fits into memory. + * + *

The default value is {@code false}. + * + * @param allowUnseekableMedia Whether a non-zero start position in unseekable media is allowed. + * @return This builder. + */ + @CanIgnoreReturnValue + public Builder setAllowUnseekableMedia(boolean allowUnseekableMedia) { + checkState(!buildCalled); + this.allowUnseekableMedia = allowUnseekableMedia; + return this; + } + /** Builds the {@link ClippingMediaSource}. */ public ClippingMediaSource build() { buildCalled = true; @@ -259,6 +279,7 @@ public final class ClippingMediaSource extends WrappingMediaSource { private final boolean enableInitialDiscontinuity; private final boolean allowDynamicClippingUpdates; private final boolean relativeToDefaultPosition; + private final boolean allowUnseekableMedia; private final ArrayList mediaPeriods; private final Timeline.Window window; @@ -313,6 +334,7 @@ public final class ClippingMediaSource extends WrappingMediaSource { this.enableInitialDiscontinuity = builder.enableInitialDiscontinuity; this.allowDynamicClippingUpdates = builder.allowDynamicClippingUpdates; this.relativeToDefaultPosition = builder.relativeToDefaultPosition; + this.allowUnseekableMedia = builder.allowUnseekableMedia; mediaPeriods = new ArrayList<>(); window = new Timeline.Window(); } @@ -398,7 +420,8 @@ public final class ClippingMediaSource extends WrappingMediaSource { : periodEndUs - windowPositionInPeriodUs; } try { - clippingTimeline = new ClippingTimeline(timeline, windowStartUs, windowEndUs); + clippingTimeline = + new ClippingTimeline(timeline, windowStartUs, windowEndUs, allowUnseekableMedia); } catch (IllegalClippingException e) { clippingError = e; // The clipping error won't be propagated while we have existing MediaPeriods. Setting the @@ -426,9 +449,11 @@ public final class ClippingMediaSource extends WrappingMediaSource { * @param startUs The number of microseconds to clip from the start of {@code timeline}. * @param endUs The end position in microseconds for the clipped timeline relative to the start * of {@code timeline}, or {@link C#TIME_END_OF_SOURCE} to clip no samples from the end. + * @param allowUnseekableMedia Whether to allow non-zero start positions in unseekable media. * @throws IllegalClippingException If the timeline could not be clipped. */ - public ClippingTimeline(Timeline timeline, long startUs, long endUs) + public ClippingTimeline( + Timeline timeline, long startUs, long endUs, boolean allowUnseekableMedia) throws IllegalClippingException { super(timeline); if (timeline.getPeriodCount() != 1) { @@ -436,7 +461,7 @@ public final class ClippingMediaSource extends WrappingMediaSource { } Window window = timeline.getWindow(0, new Window()); startUs = max(0, startUs); - if (!window.isPlaceholder && startUs != 0 && !window.isSeekable) { + if (!allowUnseekableMedia && !window.isPlaceholder && startUs != 0 && !window.isSeekable) { throw new IllegalClippingException(IllegalClippingException.REASON_NOT_SEEKABLE_TO_START); } long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : max(0, endUs); 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 1a6ea97992..6ccbdcb252 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 @@ -24,6 +24,7 @@ import androidx.media3.common.C; import androidx.media3.common.Format; import androidx.media3.common.MimeTypes; import androidx.media3.common.TrackGroup; +import androidx.media3.common.util.NullableType; import androidx.media3.decoder.DecoderInputBuffer; import androidx.media3.exoplayer.FormatHolder; import androidx.media3.exoplayer.LoadingInfo; @@ -173,6 +174,26 @@ public class ClippingMediaPeriodTest { assertThat(discontinuityPositionUs).isEqualTo(250); } + @Test + public void + readDiscontinuity_prepareFromNonZeroClipStartPositionWithUnseekableStream_returnsPreparePosition() + throws Exception { + TrackGroupArray trackGroups = + new TrackGroupArray(AUDIO_TRACK_GROUP_ALL_SYNC_SAMPLES, VIDEO_TRACK_GROUP); + ClippingMediaPeriod clippingMediaPeriod = + new ClippingMediaPeriod( + getUnseekableFakeMediaPeriod(trackGroups), + /* enableInitialDiscontinuity= */ true, + /* startUs= */ 250, + /* endUs= */ 500); + + prepareMediaPeriodAndSelectTracks( + clippingMediaPeriod, /* preparePositionUs= */ 250, trackGroups); + long discontinuityPositionUs = clippingMediaPeriod.readDiscontinuity(); + + assertThat(discontinuityPositionUs).isEqualTo(250); + } + @Test public void readDiscontinuity_prepareFromZero_returnsUnset() throws Exception { TrackGroupArray trackGroups = @@ -263,6 +284,24 @@ public class ClippingMediaPeriodTest { assertThat(discontinuityPositionUs).isEqualTo(C.TIME_UNSET); } + @Test + public void seekTo_withUnseekableMedia_returnsAtLeastStartPositionUs() throws Exception { + TrackGroupArray trackGroups = + new TrackGroupArray(AUDIO_TRACK_GROUP_ALL_SYNC_SAMPLES, VIDEO_TRACK_GROUP); + ClippingMediaPeriod clippingMediaPeriod = + new ClippingMediaPeriod( + getUnseekableFakeMediaPeriod(trackGroups), + /* enableInitialDiscontinuity= */ true, + /* startUs= */ 300, + /* endUs= */ 500); + prepareMediaPeriodAndSelectTracks( + clippingMediaPeriod, /* preparePositionUs= */ 400, trackGroups); + + long seekPositionUs = clippingMediaPeriod.seekToUs(350); + + assertThat(seekPositionUs).isAtLeast(300); + } + private static SampleStream[] prepareMediaPeriodAndSelectTracks( MediaPeriod mediaPeriod, long preparePositionUs, TrackGroupArray trackGroups) throws TimeoutException { @@ -307,4 +346,33 @@ public class ClippingMediaPeriodTest { new DrmSessionEventListener.EventDispatcher(), /* deferOnPrepared= */ false); } + + private static FakeMediaPeriod getUnseekableFakeMediaPeriod(TrackGroupArray trackGroups) { + return 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 + public long seekToUs(long positionUs) { + return super.seekToUs(/* positionUs= */ 0); + } + + @Override + public long selectTracks( + @NullableType ExoTrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + return super.selectTracks( + selections, mayRetainStreamFlags, streams, streamResetFlags, /* positionUs= */ 0); + } + }; + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java index e2f765b6e3..5c985cdc86 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ClippingMediaSourceTest.java @@ -60,7 +60,7 @@ public final class ClippingMediaSourceTest { } @Test - public void noClipping() throws IOException { + public void noClipping_returnsExpectedTimeline() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -81,7 +81,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingUnseekableWindowThrows() throws IOException { + public void clipping_withUnseekableWindow_throws() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -103,7 +103,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingUnseekableWindowWithUnknownDurationThrows() throws IOException { + public void clipping_withUnseekableWindowWithUnknownDuration_throws() throws IOException { Timeline timeline = new SinglePeriodTimeline( /* durationUs= */ C.TIME_UNSET, @@ -125,7 +125,34 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingStartExceedsEndThrows() throws IOException { + public void clipping_withUnseekableWindowAndAllowedUnseekableMedia_returnsExpectedTimeline() + throws IOException { + Timeline timeline = + new SinglePeriodTimeline( + TEST_PERIOD_DURATION_US, + /* isSeekable= */ false, + /* isDynamic= */ false, + /* useLiveConfiguration= */ false, + /* manifest= */ null, + MediaItem.fromUri(Uri.EMPTY)); + FakeMediaSource fakeMediaSource = new FakeMediaSource(timeline); + ClippingMediaSource mediaSource = + new ClippingMediaSource.Builder(fakeMediaSource) + .setStartPositionUs(1) + .setEndPositionUs(TEST_PERIOD_DURATION_US - 1) + .setAllowUnseekableMedia(true) + .build(); + + Timeline clippedTimeline = getClippedTimelines(fakeMediaSource, mediaSource)[0]; + + assertThat(clippedTimeline.getWindow(0, window).getDurationUs()) + .isEqualTo(TEST_PERIOD_DURATION_US - 2); + assertThat(clippedTimeline.getPeriod(0, period).getDurationUs()) + .isEqualTo(TEST_PERIOD_DURATION_US - 1); + } + + @Test + public void clipping_startExceedsEnd_throws() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -147,7 +174,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingStart() throws IOException { + public void clipping_startOnly_returnsExpectedTimeline() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -166,7 +193,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingEnd() throws IOException { + public void clipping_endOnly_returnsExpectedTimeline() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -185,7 +212,8 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingStartAndEndInitial() throws IOException { + public void clipping_startAndEndWithInitialPlaceHolderTimeline_returnsExpectedTimeline() + throws IOException { // Timeline that's dynamic and not seekable. A child source might report such a timeline prior // to it having loaded sufficient data to establish its duration and seekability. Such timelines // should not result in clipping failure. @@ -201,7 +229,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingToEndOfSourceWithDurationSetsDuration() throws IOException { + public void clipping_toEndOfSourceWithDuration_setsDuration() throws IOException { // Create a child timeline that has a known duration. Timeline timeline = new SinglePeriodTimeline( @@ -220,7 +248,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingToEndOfSourceWithUnsetDurationDoesNotSetDuration() throws IOException { + public void clipping_toEndOfSourceWithUnsetDuration_doesNotSetDuration() throws IOException { // Create a child timeline that has an unknown duration. Timeline timeline = new SinglePeriodTimeline( @@ -239,7 +267,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingStartAndEnd() throws IOException { + public void clipping_startAndEnd_returnsExpectedTimeline() throws IOException { Timeline timeline = new SinglePeriodTimeline( TEST_PERIOD_DURATION_US, @@ -259,7 +287,7 @@ public final class ClippingMediaSourceTest { } @Test - public void clippingFromDefaultPosition() throws IOException { + public void clipping_fromDefaultPosition_returnsExpectedTimeline() throws IOException { Timeline timeline = new SinglePeriodTimeline( /* periodDurationUs= */ 3 * TEST_PERIOD_DURATION_US, @@ -282,7 +310,8 @@ public final class ClippingMediaSourceTest { } @Test - public void allowDynamicUpdatesWithOverlappingLiveWindow() throws IOException { + public void clipping_allowDynamicUpdatesWithOverlappingLiveWindow_returnsExpectedTimelines() + throws IOException { Timeline timeline1 = new SinglePeriodTimeline( /* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US, @@ -333,7 +362,8 @@ public final class ClippingMediaSourceTest { } @Test - public void allowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException { + public void clipping_allowDynamicUpdatesWithNonOverlappingLiveWindow_returnsExpectedTimeline() + throws IOException { Timeline timeline1 = new SinglePeriodTimeline( /* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US, @@ -384,7 +414,8 @@ public final class ClippingMediaSourceTest { } @Test - public void disallowDynamicUpdatesWithOverlappingLiveWindow() throws IOException { + public void clipping_disallowDynamicUpdatesWithOverlappingLiveWindow_returnsExpectedTimeline() + throws IOException { Timeline timeline1 = new SinglePeriodTimeline( /* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US, @@ -436,7 +467,8 @@ public final class ClippingMediaSourceTest { } @Test - public void disallowDynamicUpdatesWithNonOverlappingLiveWindow() throws IOException { + public void clipping_disallowDynamicUpdatesWithNonOverlappingLiveWindow_returnsExpectedTimeline() + throws IOException { Timeline timeline1 = new SinglePeriodTimeline( /* periodDurationUs= */ 2 * TEST_PERIOD_DURATION_US, @@ -486,7 +518,7 @@ public final class ClippingMediaSourceTest { } @Test - public void windowAndPeriodIndices() throws IOException { + public void returnsExpectedTimeline_multiWindowAndPeriod_setsCorrectIndices() throws IOException { Timeline timeline = new FakeTimeline( new TimelineWindowDefinition(1, 111, true, false, TEST_PERIOD_DURATION_US));