ReplacingCuesResolver.java

/*
 * Copyright 2023 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
 *
 *      https://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.text;

import static androidx.media3.common.util.Assertions.checkArgument;

import androidx.media3.common.C;
import androidx.media3.common.text.Cue;
import androidx.media3.extractor.text.CuesWithTiming;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import java.util.ArrayList;

/**
 * A {@link CuesResolver} which resolves each time to at most one {@link CuesWithTiming} instance.
 *
 * <p>Each {@link CuesWithTiming} is used from its {@linkplain CuesWithTiming#startTimeUs start
 * time} to its {@linkplain CuesWithTiming#endTimeUs end time}, or the start time of the next
 * instance if sooner (or the end time is {@link C#TIME_UNSET}).
 *
 * <p>If the last {@link CuesWithTiming} has an {@linkplain C#TIME_UNSET unset} end time, its used
 * until the end of the playback.
 */
// TODO: b/181312195 - Add memoization
/* package */ final class ReplacingCuesResolver implements CuesResolver {

  /** Sorted by {@link CuesWithTiming#startTimeUs} ascending. */
  private final ArrayList<CuesWithTiming> cuesWithTimingList;

  public ReplacingCuesResolver() {
    cuesWithTimingList = new ArrayList<>();
  }

  @Override
  public boolean addCues(CuesWithTiming cues, long currentPositionUs) {
    checkArgument(cues.startTimeUs != C.TIME_UNSET);
    boolean cuesAreShownAtCurrentTime =
        cues.startTimeUs <= currentPositionUs
            && (cues.endTimeUs == C.TIME_UNSET || currentPositionUs < cues.endTimeUs);
    for (int i = cuesWithTimingList.size() - 1; i >= 0; i--) {
      if (cues.startTimeUs >= cuesWithTimingList.get(i).startTimeUs) {
        cuesWithTimingList.add(i + 1, cues);
        return cuesAreShownAtCurrentTime;
      } else if (cuesWithTimingList.get(i).startTimeUs <= currentPositionUs) {
        // There's a cue that starts after the new cues, but before the current time, meaning
        // the new cues will not be displayed at the current time.
        cuesAreShownAtCurrentTime = false;
      }
    }
    cuesWithTimingList.add(0, cues);
    return cuesAreShownAtCurrentTime;
  }

  @Override
  public ImmutableList<Cue> getCuesAtTimeUs(long timeUs) {
    int indexStartingAfterTimeUs = getIndexOfCuesStartingAfter(timeUs);
    if (indexStartingAfterTimeUs == 0) {
      // Either the first cue starts after timeUs, or the cues list is empty.
      return ImmutableList.of();
    }
    CuesWithTiming cues = cuesWithTimingList.get(indexStartingAfterTimeUs - 1);
    return cues.endTimeUs == C.TIME_UNSET || timeUs < cues.endTimeUs
        ? cues.cues
        : ImmutableList.of();
  }

  @Override
  public void discardCuesBeforeTimeUs(long timeUs) {
    int indexToDiscardTo = getIndexOfCuesStartingAfter(timeUs);
    if (indexToDiscardTo > 0) {
      cuesWithTimingList.subList(0, indexToDiscardTo).clear();
    }
  }

  @Override
  public long getPreviousCueChangeTimeUs(long timeUs) {
    if (cuesWithTimingList.isEmpty() || timeUs < cuesWithTimingList.get(0).startTimeUs) {
      return C.TIME_UNSET;
    }

    for (int i = 1; i < cuesWithTimingList.size(); i++) {
      long nextCuesStartTimeUs = cuesWithTimingList.get(i).startTimeUs;
      if (timeUs == nextCuesStartTimeUs) {
        return nextCuesStartTimeUs;
      }
      if (timeUs < nextCuesStartTimeUs) {
        CuesWithTiming cues = cuesWithTimingList.get(i - 1);
        return cues.endTimeUs != C.TIME_UNSET && cues.endTimeUs <= timeUs
            ? cues.endTimeUs
            : cues.startTimeUs;
      }
    }
    CuesWithTiming lastCues = Iterables.getLast(cuesWithTimingList);
    return lastCues.endTimeUs == C.TIME_UNSET || timeUs < lastCues.endTimeUs
        ? lastCues.startTimeUs
        : lastCues.endTimeUs;
  }

  @Override
  public long getNextCueChangeTimeUs(long timeUs) {
    if (cuesWithTimingList.isEmpty()) {
      return C.TIME_END_OF_SOURCE;
    }
    if (timeUs < cuesWithTimingList.get(0).startTimeUs) {
      return cuesWithTimingList.get(0).startTimeUs;
    }

    for (int i = 1; i < cuesWithTimingList.size(); i++) {
      CuesWithTiming cues = cuesWithTimingList.get(i);
      if (timeUs < cues.startTimeUs) {
        CuesWithTiming previousCues = cuesWithTimingList.get(i - 1);
        return previousCues.endTimeUs != C.TIME_UNSET
                && previousCues.endTimeUs > timeUs
                && previousCues.endTimeUs < cues.startTimeUs
            ? previousCues.endTimeUs
            : cues.startTimeUs;
      }
    }
    CuesWithTiming lastCues = Iterables.getLast(cuesWithTimingList);
    return lastCues.endTimeUs != C.TIME_UNSET && timeUs < lastCues.endTimeUs
        ? lastCues.endTimeUs
        : C.TIME_END_OF_SOURCE;
  }

  @Override
  public void clear() {
    cuesWithTimingList.clear();
  }

  /**
   * Returns the index of the first {@link CuesWithTiming} in {@link #cuesWithTimingList} where
   * {@link CuesWithTiming#startTimeUs} is strictly less than {@code timeUs}.
   *
   * <p>Returns the size of {@link #cuesWithTimingList} if all cues are before timeUs
   */
  private int getIndexOfCuesStartingAfter(long timeUs) {
    for (int i = 0; i < cuesWithTimingList.size(); i++) {
      if (timeUs < cuesWithTimingList.get(i).startTimeUs) {
        return i;
      }
    }
    return cuesWithTimingList.size();
  }
}