blob: d4ede3e59e672a787dd7cc1dd158d660b3a56ed8 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2.source;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;
import com.google.android.exoplayer2.util.Assertions;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
/**
* {@link MediaSource} that wraps a source and clips its timeline based on specified start/end
* positions. The wrapped source must consist of a single period.
*/
public final class ClippingMediaSource extends CompositeMediaSource<Void> {
/** Thrown when a {@link ClippingMediaSource} cannot clip its wrapped source. */
public static final class IllegalClippingException extends IOException {
/**
* The reason clipping failed. One of {@link #REASON_INVALID_PERIOD_COUNT}, {@link
* #REASON_NOT_SEEKABLE_TO_START} or {@link #REASON_START_EXCEEDS_END}.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@IntDef({REASON_INVALID_PERIOD_COUNT, REASON_NOT_SEEKABLE_TO_START, REASON_START_EXCEEDS_END})
public @interface Reason {}
/** The wrapped source doesn't consist of a single period. */
public static final int REASON_INVALID_PERIOD_COUNT = 0;
/** The wrapped source is not seekable and a non-zero clipping start position was specified. */
public static final int REASON_NOT_SEEKABLE_TO_START = 1;
/** The wrapped source ends before the specified clipping start position. */
public static final int REASON_START_EXCEEDS_END = 2;
/** The reason clipping failed. */
public final @Reason int reason;
/**
* @param reason The reason clipping failed.
*/
public IllegalClippingException(@Reason int reason) {
super("Illegal clipping: " + getReasonDescription(reason));
this.reason = reason;
}
private static String getReasonDescription(@Reason int reason) {
switch (reason) {
case REASON_INVALID_PERIOD_COUNT:
return "invalid period count";
case REASON_NOT_SEEKABLE_TO_START:
return "not seekable to start";
case REASON_START_EXCEEDS_END:
return "start exceeds end";
default:
return "unknown";
}
}
}
private final MediaSource mediaSource;
private final long startUs;
private final long endUs;
private final boolean enableInitialDiscontinuity;
private final boolean allowDynamicClippingUpdates;
private final boolean relativeToDefaultPosition;
private final ArrayList<ClippingMediaPeriod> mediaPeriods;
private final Timeline.Window window;
@Nullable private ClippingTimeline clippingTimeline;
@Nullable private IllegalClippingException clippingError;
private long periodStartUs;
private long periodEndUs;
/**
* Creates a new clipping source that wraps the specified source and provides samples between the
* specified start and end position.
*
* @param mediaSource The single-period source to wrap.
* @param startPositionUs The start position within {@code mediaSource}'s window at which to start
* providing samples, in microseconds.
* @param endPositionUs The end position within {@code mediaSource}'s window at which to stop
* providing samples, in microseconds. Specify {@link C#TIME_END_OF_SOURCE} to provide samples
* from the specified start point up to the end of the source. Specifying a position that
* exceeds the {@code mediaSource}'s duration will also result in the end of the source not
* being clipped.
*/
public ClippingMediaSource(MediaSource mediaSource, long startPositionUs, long endPositionUs) {
this(
mediaSource,
startPositionUs,
endPositionUs,
/* enableInitialDiscontinuity= */ true,
/* allowDynamicClippingUpdates= */ false,
/* relativeToDefaultPosition= */ false);
}
/**
* Creates a new clipping source that wraps the specified source and provides samples from the
* default position for the specified duration.
*
* @param mediaSource The single-period source to wrap.
* @param durationUs The duration from the default position in the window in {@code mediaSource}'s
* timeline at which to stop providing samples. Specifying a duration that exceeds the {@code
* mediaSource}'s duration will result in the end of the source not being clipped.
*/
public ClippingMediaSource(MediaSource mediaSource, long durationUs) {
this(
mediaSource,
/* startPositionUs= */ 0,
/* endPositionUs= */ durationUs,
/* enableInitialDiscontinuity= */ true,
/* allowDynamicClippingUpdates= */ false,
/* relativeToDefaultPosition= */ true);
}
/**
* Creates a new clipping source that wraps the specified source.
*
* <p>If the start point is guaranteed to be a key frame, pass {@code false} to {@code
* enableInitialPositionDiscontinuity} to suppress an initial discontinuity when a period is first
* read from.
*
* <p>For live streams, if the clipping positions should move with the live window, pass {@code
* true} to {@code allowDynamicClippingUpdates}. Otherwise, the live stream ends when the playback
* reaches {@code endPositionUs} in the last reported live window at the time a media period was
* created.
*
* @param mediaSource The single-period source to wrap.
* @param startPositionUs The start position at which to start providing samples, in microseconds.
* If {@code relativeToDefaultPosition} is {@code false}, this position is relative to the
* start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition}
* is {@code true}, this position is relative to the default position in the window in {@code
* mediaSource}'s timeline.
* @param endPositionUs The end position at which to stop providing samples, in microseconds.
* Specify {@link C#TIME_END_OF_SOURCE} to provide samples from the specified start point up
* to the end of the source. Specifying a position that exceeds the {@code mediaSource}'s
* duration will also result in the end of the source not being clipped. If {@code
* relativeToDefaultPosition} is {@code false}, the specified position is relative to the
* start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition}
* is {@code true}, this position is relative to the default position in the window in {@code
* mediaSource}'s timeline.
* @param enableInitialDiscontinuity Whether the initial discontinuity should be enabled.
* @param allowDynamicClippingUpdates Whether the clipping of active media periods moves with a
* live window. If {@code false}, playback ends when it reaches {@code endPositionUs} in the
* last reported live window at the time a media period was created.
* @param relativeToDefaultPosition Whether {@code startPositionUs} and {@code endPositionUs} are
* relative to the default position in the window in {@code mediaSource}'s timeline.
*/
public ClippingMediaSource(
MediaSource mediaSource,
long startPositionUs,
long endPositionUs,
boolean enableInitialDiscontinuity,
boolean allowDynamicClippingUpdates,
boolean relativeToDefaultPosition) {
Assertions.checkArgument(startPositionUs >= 0);
this.mediaSource = Assertions.checkNotNull(mediaSource);
startUs = startPositionUs;
endUs = endPositionUs;
this.enableInitialDiscontinuity = enableInitialDiscontinuity;
this.allowDynamicClippingUpdates = allowDynamicClippingUpdates;
this.relativeToDefaultPosition = relativeToDefaultPosition;
mediaPeriods = new ArrayList<>();
window = new Timeline.Window();
}
@Override
@Nullable
public Object getTag() {
return mediaSource.getTag();
}
@Override
protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
prepareChildSource(/* id= */ null, mediaSource);
}
@Override
public void maybeThrowSourceInfoRefreshError() throws IOException {
if (clippingError != null) {
throw clippingError;
}
super.maybeThrowSourceInfoRefreshError();
}
@Override
public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) {
ClippingMediaPeriod mediaPeriod =
new ClippingMediaPeriod(
mediaSource.createPeriod(id, allocator, startPositionUs),
enableInitialDiscontinuity,
periodStartUs,
periodEndUs);
mediaPeriods.add(mediaPeriod);
return mediaPeriod;
}
@Override
public void releasePeriod(MediaPeriod mediaPeriod) {
Assertions.checkState(mediaPeriods.remove(mediaPeriod));
mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod);
if (mediaPeriods.isEmpty() && !allowDynamicClippingUpdates) {
refreshClippedTimeline(Assertions.checkNotNull(clippingTimeline).timeline);
}
}
@Override
protected void releaseSourceInternal() {
super.releaseSourceInternal();
clippingError = null;
clippingTimeline = null;
}
@Override
protected void onChildSourceInfoRefreshed(Void id, MediaSource mediaSource, Timeline timeline) {
if (clippingError != null) {
return;
}
refreshClippedTimeline(timeline);
}
private void refreshClippedTimeline(Timeline timeline) {
long windowStartUs;
long windowEndUs;
timeline.getWindow(/* windowIndex= */ 0, window);
long windowPositionInPeriodUs = window.getPositionInFirstPeriodUs();
if (clippingTimeline == null || mediaPeriods.isEmpty() || allowDynamicClippingUpdates) {
windowStartUs = startUs;
windowEndUs = endUs;
if (relativeToDefaultPosition) {
long windowDefaultPositionUs = window.getDefaultPositionUs();
windowStartUs += windowDefaultPositionUs;
windowEndUs += windowDefaultPositionUs;
}
periodStartUs = windowPositionInPeriodUs + windowStartUs;
periodEndUs =
endUs == C.TIME_END_OF_SOURCE
? C.TIME_END_OF_SOURCE
: windowPositionInPeriodUs + windowEndUs;
int count = mediaPeriods.size();
for (int i = 0; i < count; i++) {
mediaPeriods.get(i).updateClipping(periodStartUs, periodEndUs);
}
} else {
// Keep window fixed at previous period position.
windowStartUs = periodStartUs - windowPositionInPeriodUs;
windowEndUs =
endUs == C.TIME_END_OF_SOURCE
? C.TIME_END_OF_SOURCE
: periodEndUs - windowPositionInPeriodUs;
}
try {
clippingTimeline = new ClippingTimeline(timeline, windowStartUs, windowEndUs);
} catch (IllegalClippingException e) {
clippingError = e;
return;
}
refreshSourceInfo(clippingTimeline);
}
@Override
protected long getMediaTimeForChildMediaTime(Void id, long mediaTimeMs) {
if (mediaTimeMs == C.TIME_UNSET) {
return C.TIME_UNSET;
}
long startMs = C.usToMs(startUs);
long clippedTimeMs = Math.max(0, mediaTimeMs - startMs);
if (endUs != C.TIME_END_OF_SOURCE) {
clippedTimeMs = Math.min(C.usToMs(endUs) - startMs, clippedTimeMs);
}
return clippedTimeMs;
}
/**
* Provides a clipped view of a specified timeline.
*/
private static final class ClippingTimeline extends ForwardingTimeline {
private final long startUs;
private final long endUs;
private final long durationUs;
private final boolean isDynamic;
/**
* Creates a new clipping timeline that wraps the specified timeline.
*
* @param timeline The timeline to clip.
* @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.
* @throws IllegalClippingException If the timeline could not be clipped.
*/
public ClippingTimeline(Timeline timeline, long startUs, long endUs)
throws IllegalClippingException {
super(timeline);
if (timeline.getPeriodCount() != 1) {
throw new IllegalClippingException(IllegalClippingException.REASON_INVALID_PERIOD_COUNT);
}
Window window = timeline.getWindow(0, new Window());
startUs = Math.max(0, startUs);
if (!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 : Math.max(0, endUs);
if (window.durationUs != C.TIME_UNSET) {
if (resolvedEndUs > window.durationUs) {
resolvedEndUs = window.durationUs;
}
if (startUs > resolvedEndUs) {
throw new IllegalClippingException(IllegalClippingException.REASON_START_EXCEEDS_END);
}
}
this.startUs = startUs;
this.endUs = resolvedEndUs;
durationUs = resolvedEndUs == C.TIME_UNSET ? C.TIME_UNSET : (resolvedEndUs - startUs);
isDynamic =
window.isDynamic
&& (resolvedEndUs == C.TIME_UNSET
|| (window.durationUs != C.TIME_UNSET && resolvedEndUs == window.durationUs));
}
@Override
public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) {
timeline.getWindow(/* windowIndex= */ 0, window, /* defaultPositionProjectionUs= */ 0);
window.positionInFirstPeriodUs += startUs;
window.durationUs = durationUs;
window.isDynamic = isDynamic;
if (window.defaultPositionUs != C.TIME_UNSET) {
window.defaultPositionUs = Math.max(window.defaultPositionUs, startUs);
window.defaultPositionUs = endUs == C.TIME_UNSET ? window.defaultPositionUs
: Math.min(window.defaultPositionUs, endUs);
window.defaultPositionUs -= startUs;
}
long startMs = C.usToMs(startUs);
if (window.presentationStartTimeMs != C.TIME_UNSET) {
window.presentationStartTimeMs += startMs;
}
if (window.windowStartTimeMs != C.TIME_UNSET) {
window.windowStartTimeMs += startMs;
}
return window;
}
@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
timeline.getPeriod(/* periodIndex= */ 0, period, setIds);
long positionInClippedWindowUs = period.getPositionInWindowUs() - startUs;
long periodDurationUs =
durationUs == C.TIME_UNSET ? C.TIME_UNSET : durationUs - positionInClippedWindowUs;
return period.set(
period.id, period.uid, /* windowIndex= */ 0, periodDurationUs, positionInClippedWindowUs);
}
}
}