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