diff --git a/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java b/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java index 99b329ebf8..62414636c8 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/MediaItem.java @@ -57,6 +57,11 @@ public final class MediaItem { @Nullable private String mediaId; @Nullable private Uri sourceUri; @Nullable private String mimeType; + private long clipStartPositionMs; + private long clipEndPositionMs; + private boolean clipRelativeToLiveWindow; + private boolean clipRelativeToDefaultPosition; + private boolean clipStartsAtKeyFrame; @Nullable private Uri drmLicenseUri; private Map drmLicenseRequestHeaders; @Nullable private UUID drmUuid; @@ -74,6 +79,7 @@ public final class MediaItem { subtitles = Collections.emptyList(); drmSessionForClearTypes = Collections.emptyList(); drmLicenseRequestHeaders = Collections.emptyMap(); + clipEndPositionMs = C.TIME_END_OF_SOURCE; } /** @@ -117,6 +123,55 @@ public final class MediaItem { return this; } + /** + * Sets the optional start position in milliseconds which must be a value larger than or equal + * to zero (Default: 0). + */ + public Builder setClipStartPositionMs(long startPositionMs) { + Assertions.checkArgument(startPositionMs >= 0); + this.clipStartPositionMs = startPositionMs; + return this; + } + + /** + * Sets the optional end position in milliseconds which must be a value larger than or equal to + * zero, or {@link C#TIME_END_OF_SOURCE} to end when playback reaches the end of media (Default: + * {@link C#TIME_END_OF_SOURCE}). + */ + public Builder setClipEndPositionMs(long endPositionMs) { + Assertions.checkArgument(endPositionMs == C.TIME_END_OF_SOURCE || endPositionMs >= 0); + this.clipEndPositionMs = endPositionMs; + return this; + } + + /** + * Sets whether the start/end positions should move with the live window for live streams. If + * {@code false}, live streams end when playback reaches the end position in live window seen + * when the media is first loaded (Default: {@code false}). + */ + public Builder setClipRelativeToLiveWindow(boolean relativeToLiveWindow) { + this.clipRelativeToLiveWindow = relativeToLiveWindow; + return this; + } + + /** + * Sets whether the start position and the end position are relative to the default position in + * the window (Default: {@code false}). + */ + public Builder setClipRelativeToDefaultPosition(boolean relativeToDefaultPosition) { + this.clipRelativeToDefaultPosition = relativeToDefaultPosition; + return this; + } + + /** + * Sets whether the start point is guaranteed to be a key frame. If {@code false}, the playback + * transition into the clip may not be seamless (Default: {@code false}). + */ + public Builder setClipStartsAtKeyFrame(boolean startsAtKeyFrame) { + this.clipStartsAtKeyFrame = startsAtKeyFrame; + return this; + } + /** * Sets the optional license server {@link Uri}. If a license uri is set, the {@link * DrmConfiguration#uuid} needs to be specified as well. @@ -303,6 +358,12 @@ public final class MediaItem { } return new MediaItem( Assertions.checkNotNull(mediaId), + new ClippingProperties( + clipStartPositionMs, + clipEndPositionMs, + clipRelativeToLiveWindow, + clipRelativeToDefaultPosition, + clipStartsAtKeyFrame), playbackProperties, mediaMetadata != null ? mediaMetadata : new MediaMetadata.Builder().build()); } @@ -521,6 +582,75 @@ public final class MediaItem { } } + /** Optionally clips the media item to a custom start and end position. */ + public static final class ClippingProperties { + + /** The start position in milliseconds. This is a value larger than or equal to zero. */ + public final long startPositionMs; + + /** + * The end position in milliseconds. This is a value larger than or equal to zero or {@link + * C#TIME_END_OF_SOURCE} to play to the end of the stream. + */ + public final long endPositionMs; + + /** + * Whether the clipping of active media periods moves with a live window. If {@code false}, + * playback ends when it reaches {@link #endPositionMs}. + */ + public final boolean relativeToLiveWindow; + + /** + * Whether {@link #startPositionMs} and {@link #endPositionMs} are relative to the default + * position. + */ + public final boolean relativeToDefaultPosition; + + /** Sets whether the start point is guaranteed to be a key frame. */ + public final boolean startsAtKeyFrame; + + private ClippingProperties( + long startPositionMs, + long endPositionMs, + boolean relativeToLiveWindow, + boolean relativeToDefaultPosition, + boolean startsAtKeyFrame) { + this.startPositionMs = startPositionMs; + this.endPositionMs = endPositionMs; + this.relativeToLiveWindow = relativeToLiveWindow; + this.relativeToDefaultPosition = relativeToDefaultPosition; + this.startsAtKeyFrame = startsAtKeyFrame; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof ClippingProperties)) { + return false; + } + + ClippingProperties other = (ClippingProperties) obj; + + return startPositionMs == other.startPositionMs + && endPositionMs == other.endPositionMs + && relativeToLiveWindow == other.relativeToLiveWindow + && relativeToDefaultPosition == other.relativeToDefaultPosition + && startsAtKeyFrame == other.startsAtKeyFrame; + } + + @Override + public int hashCode() { + int result = Long.valueOf(startPositionMs).hashCode(); + result = 31 * result + Long.valueOf(endPositionMs).hashCode(); + result = 31 * result + (relativeToLiveWindow ? 1 : 0); + result = 31 * result + (relativeToDefaultPosition ? 1 : 0); + result = 31 * result + (startsAtKeyFrame ? 1 : 0); + return result; + } + } + /** Identifies the media item. */ public final String mediaId; @@ -530,13 +660,18 @@ public final class MediaItem { /** The media metadata. */ public final MediaMetadata mediaMetadata; + /** The clipping properties. */ + public final ClippingProperties clippingProperties; + private MediaItem( String mediaId, + ClippingProperties clippingProperties, @Nullable PlaybackProperties playbackProperties, MediaMetadata mediaMetadata) { this.mediaId = mediaId; this.playbackProperties = playbackProperties; this.mediaMetadata = mediaMetadata; + this.clippingProperties = clippingProperties; } @Override @@ -551,6 +686,7 @@ public final class MediaItem { MediaItem other = (MediaItem) obj; return Util.areEqual(mediaId, other.mediaId) + && clippingProperties.equals(other.clippingProperties) && Util.areEqual(playbackProperties, other.playbackProperties) && Util.areEqual(mediaMetadata, other.mediaMetadata); } @@ -559,6 +695,7 @@ public final class MediaItem { public int hashCode() { int result = mediaId.hashCode(); result = 31 * result + (playbackProperties != null ? playbackProperties.hashCode() : 0); + result = 31 * result + clippingProperties.hashCode(); result = 31 * result + mediaMetadata.hashCode(); return result; } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java index 4bdb46b484..c3beb0d00e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactory.java @@ -216,11 +216,11 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { MediaSource leafMediaSource = mediaSourceFactory.createMediaSource(mediaItem); - if (mediaItem.playbackProperties.subtitles.isEmpty()) { - return leafMediaSource; + List subtitles = mediaItem.playbackProperties.subtitles; + if (subtitles.isEmpty()) { + return maybeClipMediaSource(mediaItem, leafMediaSource); } - List subtitles = mediaItem.playbackProperties.subtitles; MediaSource[] mediaSources = new MediaSource[subtitles.size() + 1]; mediaSources[0] = leafMediaSource; SingleSampleMediaSource.Factory singleSampleSourceFactory = @@ -234,9 +234,10 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { .setSelectionFlags(subtitle.selectionFlags) .build(); mediaSources[i + 1] = - singleSampleSourceFactory.createMediaSource(subtitle.uri, subtitleFormat, C.TIME_UNSET); + singleSampleSourceFactory.createMediaSource( + subtitle.uri, subtitleFormat, /* durationUs= */ C.TIME_UNSET); } - return new MergingMediaSource(mediaSources); + return maybeClipMediaSource(mediaItem, new MergingMediaSource(mediaSources)); } // internal methods @@ -269,6 +270,21 @@ public final class DefaultMediaSourceFactory implements MediaSourceFactory { return drmCallback; } + private static MediaSource maybeClipMediaSource(MediaItem mediaItem, MediaSource mediaSource) { + if (mediaItem.clippingProperties.startPositionMs == 0 + && mediaItem.clippingProperties.endPositionMs == C.TIME_END_OF_SOURCE + && !mediaItem.clippingProperties.relativeToDefaultPosition) { + return mediaSource; + } + return new ClippingMediaSource( + mediaSource, + C.msToUs(mediaItem.clippingProperties.startPositionMs), + C.msToUs(mediaItem.clippingProperties.endPositionMs), + /* enableInitialDiscontinuity= */ !mediaItem.clippingProperties.startsAtKeyFrame, + /* allowDynamicClippingUpdates= */ mediaItem.clippingProperties.relativeToLiveWindow, + mediaItem.clippingProperties.relativeToDefaultPosition); + } + private static SparseArray loadDelegates( DataSource.Factory dataSourceFactory) { SparseArray factories = new SparseArray<>(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java b/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java index d6505b9138..bb470114d3 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/MediaItemTest.java @@ -187,6 +187,77 @@ public class MediaItemTest { assertThat(mediaItem.playbackProperties.tag).isEqualTo(tag); } + @Test + public void builderSetStartPositionMs_setsStartPositionMs() { + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_STRING).setClipStartPositionMs(1000L).build(); + + assertThat(mediaItem.clippingProperties.startPositionMs).isEqualTo(1000L); + } + + @Test + public void builderSetStartPositionMs_zeroByDefault() { + MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + + assertThat(mediaItem.clippingProperties.startPositionMs).isEqualTo(0); + } + + @Test + public void builderSetStartPositionMs_negativeValue_throws() { + MediaItem.Builder builder = new MediaItem.Builder(); + + assertThrows(IllegalArgumentException.class, () -> builder.setClipStartPositionMs(-1)); + } + + @Test + public void builderSetEndPositionMs_setsEndPositionMs() { + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_STRING).setClipEndPositionMs(1000L).build(); + + assertThat(mediaItem.clippingProperties.endPositionMs).isEqualTo(1000L); + } + + @Test + public void builderSetEndPositionMs_timeEndOfSourceByDefault() { + MediaItem mediaItem = new MediaItem.Builder().setSourceUri(URI_STRING).build(); + + assertThat(mediaItem.clippingProperties.endPositionMs).isEqualTo(C.TIME_END_OF_SOURCE); + } + + @Test + public void builderSetEndPositionMs_timeEndOfSource_setsEndPositionMs() { + MediaItem mediaItem = + new MediaItem.Builder() + .setSourceUri(URI_STRING) + .setClipEndPositionMs(1000) + .setClipEndPositionMs(C.TIME_END_OF_SOURCE) + .build(); + + assertThat(mediaItem.clippingProperties.endPositionMs).isEqualTo(C.TIME_END_OF_SOURCE); + } + + @Test + public void builderSetEndPositionMs_negativeValue_throws() { + MediaItem.Builder builder = new MediaItem.Builder(); + + assertThrows(IllegalArgumentException.class, () -> builder.setClipEndPositionMs(-1)); + } + + @Test + public void builderSetClippingFlags_setsClippingFlags() { + MediaItem mediaItem = + new MediaItem.Builder() + .setSourceUri(URI_STRING) + .setClipRelativeToDefaultPosition(true) + .setClipRelativeToLiveWindow(true) + .setClipStartsAtKeyFrame(true) + .build(); + + assertThat(mediaItem.clippingProperties.relativeToDefaultPosition).isTrue(); + assertThat(mediaItem.clippingProperties.relativeToLiveWindow).isTrue(); + assertThat(mediaItem.clippingProperties.startsAtKeyFrame).isTrue(); + } + @Test public void builderSetMediaMetadata_setsMetadata() { MediaMetadata mediaMetadata = new MediaMetadata.Builder().setTitle("title").build(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java index 2bb4ca16cf..3c9d5182f8 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/DefaultMediaSourceFactoryTest.java @@ -122,6 +122,60 @@ public final class DefaultMediaSourceFactoryTest { assertThat(mediaSource.getTag()).isEqualTo(tag); } + @Test + public void createMediaSource_withStartPosition_isClippingMediaSource() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setClipStartPositionMs(1000L).build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(ClippingMediaSource.class); + } + + @Test + public void createMediaSource_withEndPosition_isClippingMediaSource() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = + new MediaItem.Builder().setSourceUri(URI_MEDIA).setClipEndPositionMs(1000L).build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(ClippingMediaSource.class); + } + + @Test + public void createMediaSource_relativeToDefaultPosition_isClippingMediaSource() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = + new MediaItem.Builder() + .setSourceUri(URI_MEDIA) + .setClipRelativeToDefaultPosition(true) + .build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(ClippingMediaSource.class); + } + + @Test + public void createMediaSource_defaultToEnd_isNotClippingMediaSource() { + DefaultMediaSourceFactory defaultMediaSourceFactory = + DefaultMediaSourceFactory.newInstance(ApplicationProvider.getApplicationContext()); + MediaItem mediaItem = + new MediaItem.Builder() + .setSourceUri(URI_MEDIA) + .setClipEndPositionMs(C.TIME_END_OF_SOURCE) + .build(); + + MediaSource mediaSource = defaultMediaSourceFactory.createMediaSource(mediaItem); + + assertThat(mediaSource).isInstanceOf(ProgressiveMediaSource.class); + } + @Test public void getSupportedTypes_coreModule_onlyOther() { int[] supportedTypes =