ClippingMediaSource.java

/*
 * 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 androidx.media3.exoplayer.source;

import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.annotation.ElementType.TYPE_USE;

import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Assertions;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.TransferListener;
import androidx.media3.exoplayer.upstream.Allocator;
import java.io.IOException;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
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.
 */
@UnstableApi
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)
    @Target(TYPE_USE)
    @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
  public MediaItem getMediaItem() {
    return mediaSource.getMediaItem();
  }

  @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;
      // The clipping error won't be propagated while we have existing MediaPeriods. Setting the
      // error at the MediaPeriods ensures it will be thrown as soon as possible.
      for (int i = 0; i < mediaPeriods.size(); i++) {
        mediaPeriods.get(i).setClippingError(clippingError);
      }
      return;
    }
    refreshSourceInfo(clippingTimeline);
  }

  /** 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 = 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 : 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 = max(window.defaultPositionUs, startUs);
        window.defaultPositionUs =
            endUs == C.TIME_UNSET ? window.defaultPositionUs : min(window.defaultPositionUs, endUs);
        window.defaultPositionUs -= startUs;
      }
      long startMs = Util.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);
    }
  }
}