diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index 6d65da2cfa9..cc427b8cfc0 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -1,5 +1,14 @@
# Release notes
+### Unreleased changes
+
+* Video:
+* Text:
+ * Remove `ExoplayerCuesDecoder`. Text tracks with `sampleMimeType =
+ application/x-media3-cues` are now directly handled by `TextRenderer`
+ without needing a `SubtitleDecoder` instance.
+
+
## 1.2
### 1.2.0-beta01 (2023-10-18)
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/CuesResolver.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/CuesResolver.java
new file mode 100644
index 00000000000..86652b9e13f
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/CuesResolver.java
@@ -0,0 +1,70 @@
+/*
+ * 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 androidx.media3.common.C;
+import androidx.media3.common.text.Cue;
+import androidx.media3.common.text.CueGroup;
+import androidx.media3.extractor.text.CuesWithTiming;
+import com.google.common.collect.ImmutableList;
+
+/**
+ * A {@code CuesResolver} maps from time to the subtitle cues that should be shown.
+ *
+ *
It also exposes methods for querying when the next and previous change in subtitles is.
+ *
+ *
Different implementations may provide different resolution algorithms.
+ */
+/* package */ interface CuesResolver {
+
+ /** Adds cues to this instance. */
+ void addCues(CuesWithTiming cues);
+
+ /**
+ * Returns the {@linkplain Cue cues} that should be shown at time {@code timeUs}.
+ *
+ * @param timeUs The time to query, in microseconds.
+ * @return The cues that should be shown, ordered by ascending priority for compatibility with
+ * {@link CueGroup#cues}.
+ */
+ ImmutableList getCuesAtTimeUs(long timeUs);
+
+ /**
+ * Discards all cues that won't be shown at or after {@code timeUs}.
+ *
+ * @param timeUs The time to discard cues before, in microseconds.
+ */
+ void discardCuesBeforeTimeUs(long timeUs);
+
+ /**
+ * Returns the time, in microseconds, of the change in {@linkplain #getCuesAtTimeUs(long) cue
+ * output} at or before {@code timeUs}.
+ *
+ * If there's no change before {@code timeUs}, returns {@link C#TIME_UNSET}.
+ */
+ long getPreviousCueChangeTimeUs(long timeUs);
+
+ /**
+ * Returns the time, in microseconds, of the next change in {@linkplain #getCuesAtTimeUs(long) cue
+ * output} after {@code timeUs} (exclusive).
+ *
+ *
If there's no change after {@code timeUs}, returns {@link C#TIME_END_OF_SOURCE}.
+ */
+ long getNextCueChangeTimeUs(long timeUs);
+
+ /** Clears all cues that have been added to this instance. */
+ void clear();
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoder.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoder.java
deleted file mode 100644
index ac7c63baf37..00000000000
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoder.java
+++ /dev/null
@@ -1,158 +0,0 @@
-/*
- * Copyright 2021 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.text;
-
-import static androidx.media3.common.util.Assertions.checkArgument;
-import static androidx.media3.common.util.Assertions.checkNotNull;
-import static androidx.media3.common.util.Assertions.checkState;
-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.MimeTypes;
-import androidx.media3.common.util.UnstableApi;
-import androidx.media3.extractor.text.CueDecoder;
-import androidx.media3.extractor.text.CuesWithTimingSubtitle;
-import androidx.media3.extractor.text.Subtitle;
-import androidx.media3.extractor.text.SubtitleDecoder;
-import androidx.media3.extractor.text.SubtitleDecoderException;
-import androidx.media3.extractor.text.SubtitleInputBuffer;
-import androidx.media3.extractor.text.SubtitleOutputBuffer;
-import com.google.common.collect.ImmutableList;
-import java.lang.annotation.Documented;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-import java.util.ArrayDeque;
-import java.util.Deque;
-
-/**
- * A {@link SubtitleDecoder} that decodes subtitle samples of type {@link
- * MimeTypes#APPLICATION_MEDIA3_CUES}
- */
-@UnstableApi
-public final class ExoplayerCuesDecoder implements SubtitleDecoder {
- @Documented
- @Target(TYPE_USE)
- @IntDef(value = {INPUT_BUFFER_AVAILABLE, INPUT_BUFFER_DEQUEUED, INPUT_BUFFER_QUEUED})
- @Retention(RetentionPolicy.SOURCE)
- private @interface InputBufferState {}
-
- private static final int INPUT_BUFFER_AVAILABLE = 0;
- private static final int INPUT_BUFFER_DEQUEUED = 1;
- private static final int INPUT_BUFFER_QUEUED = 2;
-
- private static final int OUTPUT_BUFFERS_COUNT = 2;
-
- private final CueDecoder cueDecoder;
- private final SubtitleInputBuffer inputBuffer;
- private final Deque availableOutputBuffers;
-
- private @InputBufferState int inputBufferState;
- private boolean released;
-
- public ExoplayerCuesDecoder() {
- cueDecoder = new CueDecoder();
- inputBuffer = new SubtitleInputBuffer();
- availableOutputBuffers = new ArrayDeque<>();
- for (int i = 0; i < OUTPUT_BUFFERS_COUNT; i++) {
- availableOutputBuffers.addFirst(
- new SubtitleOutputBuffer() {
- @Override
- public void release() {
- ExoplayerCuesDecoder.this.releaseOutputBuffer(this);
- }
- });
- }
- inputBufferState = INPUT_BUFFER_AVAILABLE;
- }
-
- @Override
- public String getName() {
- return "ExoplayerCuesDecoder";
- }
-
- @Override
- public void setOutputStartTimeUs(long outputStartTimeUs) {
- // Do nothing.
- }
-
- @Nullable
- @Override
- public SubtitleInputBuffer dequeueInputBuffer() throws SubtitleDecoderException {
- checkState(!released);
- if (inputBufferState != INPUT_BUFFER_AVAILABLE) {
- return null;
- }
- inputBufferState = INPUT_BUFFER_DEQUEUED;
- return inputBuffer;
- }
-
- @Override
- public void queueInputBuffer(SubtitleInputBuffer inputBuffer) throws SubtitleDecoderException {
- checkState(!released);
- checkState(inputBufferState == INPUT_BUFFER_DEQUEUED);
- checkArgument(this.inputBuffer == inputBuffer);
- inputBufferState = INPUT_BUFFER_QUEUED;
- }
-
- @Nullable
- @Override
- public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException {
- checkState(!released);
- if (inputBufferState != INPUT_BUFFER_QUEUED || availableOutputBuffers.isEmpty()) {
- return null;
- }
- SubtitleOutputBuffer outputBuffer = availableOutputBuffers.removeFirst();
- if (inputBuffer.isEndOfStream()) {
- outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
- } else {
- Subtitle subtitle =
- new CuesWithTimingSubtitle(
- ImmutableList.of(
- cueDecoder.decode(inputBuffer.timeUs, checkNotNull(inputBuffer.data).array())));
- outputBuffer.setContent(inputBuffer.timeUs, subtitle, /* subsampleOffsetUs= */ 0);
- }
- inputBuffer.clear();
- inputBufferState = INPUT_BUFFER_AVAILABLE;
- return outputBuffer;
- }
-
- @Override
- public void flush() {
- checkState(!released);
- inputBuffer.clear();
- inputBufferState = INPUT_BUFFER_AVAILABLE;
- }
-
- @Override
- public void release() {
- released = true;
- }
-
- @Override
- public void setPositionUs(long positionUs) {
- // Do nothing
- }
-
- private void releaseOutputBuffer(SubtitleOutputBuffer outputBuffer) {
- checkState(availableOutputBuffers.size() < OUTPUT_BUFFERS_COUNT);
- checkArgument(!availableOutputBuffers.contains(outputBuffer));
- outputBuffer.clear();
- availableOutputBuffers.addFirst(outputBuffer);
- }
-}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/MergingCuesResolver.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/MergingCuesResolver.java
new file mode 100644
index 00000000000..aeb01bb338f
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/MergingCuesResolver.java
@@ -0,0 +1,155 @@
+/*
+ * 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 static java.lang.Math.max;
+import static java.lang.Math.min;
+
+import androidx.media3.common.C;
+import androidx.media3.common.text.Cue;
+import androidx.media3.common.text.CueGroup;
+import androidx.media3.extractor.text.CuesWithTiming;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Ordering;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A {@link CuesResolver} which merges possibly-overlapping {@link CuesWithTiming} instances.
+ *
+ * This implementation only accepts with {@link CuesWithTiming} with a set {@link
+ * CuesWithTiming#durationUs}.
+ */
+// TODO: b/181312195 - Add memoization
+/* package */ final class MergingCuesResolver implements CuesResolver {
+
+ /**
+ * An {@link Ordering} which sorts cues in ascending display priority, for compatibility with the
+ * ordering defined for {@link CueGroup#cues}.
+ *
+ *
Sorts first by start time ascending (later cues should be shown on top of older ones), then
+ * by duration descending (shorter duration cues that start at the same time should be shown on
+ * top, as the one underneath will be visible after they disappear).
+ */
+ private static final Ordering CUES_DISPLAY_PRIORITY_COMPARATOR =
+ Ordering.natural()
+ .onResultOf((CuesWithTiming c) -> c.startTimeUs)
+ .compound(
+ Ordering.natural().reverse().onResultOf((CuesWithTiming c) -> c.durationUs));
+
+ /** Sorted by {@link CuesWithTiming#startTimeUs} ascending. */
+ private final List cuesWithTimingList;
+
+ public MergingCuesResolver() {
+ cuesWithTimingList = new ArrayList<>();
+ }
+
+ @Override
+ public void addCues(CuesWithTiming cues) {
+ checkArgument(cues.startTimeUs != C.TIME_UNSET);
+ checkArgument(cues.durationUs != C.TIME_UNSET);
+ for (int i = cuesWithTimingList.size() - 1; i >= 0; i--) {
+ if (cues.startTimeUs >= cuesWithTimingList.get(i).startTimeUs) {
+ cuesWithTimingList.add(i + 1, cues);
+ return;
+ }
+ }
+ cuesWithTimingList.add(0, cues);
+ }
+
+ @Override
+ public ImmutableList getCuesAtTimeUs(long timeUs) {
+ if (cuesWithTimingList.isEmpty() || timeUs < cuesWithTimingList.get(0).startTimeUs) {
+ return ImmutableList.of();
+ }
+
+ List visibleCues = new ArrayList<>();
+ for (int i = 0; i < cuesWithTimingList.size(); i++) {
+ CuesWithTiming cues = cuesWithTimingList.get(i);
+ if (timeUs >= cues.startTimeUs && timeUs < cues.endTimeUs) {
+ visibleCues.add(cues);
+ }
+ if (timeUs < cues.startTimeUs) {
+ break;
+ }
+ }
+ ImmutableList sortedResult =
+ ImmutableList.sortedCopyOf(CUES_DISPLAY_PRIORITY_COMPARATOR, visibleCues);
+ ImmutableList.Builder result = ImmutableList.builder();
+ for (int i = 0; i < sortedResult.size(); i++) {
+ result.addAll(sortedResult.get(i).cues);
+ }
+ return result.build();
+ }
+
+ @Override
+ public void discardCuesBeforeTimeUs(long timeUs) {
+ for (int i = 0; i < cuesWithTimingList.size(); i++) {
+ long startTimeUs = cuesWithTimingList.get(i).startTimeUs;
+ if (timeUs > startTimeUs && timeUs > cuesWithTimingList.get(i).endTimeUs) {
+ // In most cases only a single item will be removed in each invocation of this method, so
+ // the inefficiency of removing items one-by-one inside a loop is mitigated.
+ cuesWithTimingList.remove(i);
+ i--;
+ } else if (timeUs < startTimeUs) {
+ break;
+ }
+ }
+ }
+
+ @Override
+ public long getPreviousCueChangeTimeUs(long timeUs) {
+ if (cuesWithTimingList.isEmpty() || timeUs < cuesWithTimingList.get(0).startTimeUs) {
+ return C.TIME_UNSET;
+ }
+ long result = cuesWithTimingList.get(0).startTimeUs;
+ for (int i = 0; i < cuesWithTimingList.size(); i++) {
+ long startTimeUs = cuesWithTimingList.get(i).startTimeUs;
+ long endTimeUs = cuesWithTimingList.get(i).endTimeUs;
+ if (endTimeUs <= timeUs) {
+ result = max(result, endTimeUs);
+ } else if (startTimeUs <= timeUs) {
+ result = max(result, startTimeUs);
+ } else {
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public long getNextCueChangeTimeUs(long timeUs) {
+ long result = C.TIME_UNSET;
+ for (int i = 0; i < cuesWithTimingList.size(); i++) {
+ long startTimeUs = cuesWithTimingList.get(i).startTimeUs;
+ long endTimeUs = cuesWithTimingList.get(i).endTimeUs;
+ if (timeUs < startTimeUs) {
+ result = result == C.TIME_UNSET ? startTimeUs : min(result, startTimeUs);
+ break;
+ } else if (timeUs < endTimeUs) {
+ result = result == C.TIME_UNSET ? endTimeUs : min(result, endTimeUs);
+ }
+ }
+ return result != C.TIME_UNSET ? result : C.TIME_END_OF_SOURCE;
+ }
+
+ @Override
+ public void clear() {
+ cuesWithTimingList.clear();
+ }
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ReplacingCuesResolver.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ReplacingCuesResolver.java
new file mode 100644
index 00000000000..fec4add61a2
--- /dev/null
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/ReplacingCuesResolver.java
@@ -0,0 +1,146 @@
+/*
+ * 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 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.
+ *
+ * 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}).
+ *
+ *
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 cuesWithTimingList;
+
+ public ReplacingCuesResolver() {
+ cuesWithTimingList = new ArrayList<>();
+ }
+
+ @Override
+ public void addCues(CuesWithTiming cues) {
+ for (int i = cuesWithTimingList.size() - 1; i >= 0; i--) {
+ if (cues.startTimeUs >= cuesWithTimingList.get(i).startTimeUs) {
+ cuesWithTimingList.add(i + 1, cues);
+ return;
+ }
+ }
+ cuesWithTimingList.add(0, cues);
+ }
+
+ @Override
+ public ImmutableList 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}.
+ *
+ * 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();
+ }
+}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
index 3b545da21fb..1f78e393906 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java
@@ -56,7 +56,6 @@ public interface SubtitleDecoderFactory {
*
* - Cea608 ({@link Cea608Decoder})
*
- Cea708 ({@link Cea708Decoder})
- *
- Exoplayer Cues ({@link ExoplayerCuesDecoder})
*
*/
SubtitleDecoderFactory DEFAULT =
@@ -70,8 +69,7 @@ public boolean supportsFormat(Format format) {
return delegate.supportsFormat(format)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_CEA608)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_MP4CEA608)
- || Objects.equals(mimeType, MimeTypes.APPLICATION_CEA708)
- || Objects.equals(mimeType, MimeTypes.APPLICATION_MEDIA3_CUES);
+ || Objects.equals(mimeType, MimeTypes.APPLICATION_CEA708);
}
@Override
@@ -92,8 +90,6 @@ public SubtitleDecoder createDecoder(Format format) {
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
case MimeTypes.APPLICATION_CEA708:
return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
- case MimeTypes.APPLICATION_MEDIA3_CUES:
- return new ExoplayerCuesDecoder();
default:
break;
}
diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java
index 34f716561b0..62ab211c06e 100644
--- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java
+++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/TextRenderer.java
@@ -33,12 +33,15 @@
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
+import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.BaseRenderer;
import androidx.media3.exoplayer.FormatHolder;
+import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.SampleStream.ReadDataResult;
-import androidx.media3.extractor.text.Subtitle;
+import androidx.media3.extractor.text.CueDecoder;
+import androidx.media3.extractor.text.CuesWithTiming;
import androidx.media3.extractor.text.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.extractor.text.SubtitleInputBuffer;
@@ -48,16 +51,21 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
import org.checkerframework.checker.nullness.qual.RequiresNonNull;
import org.checkerframework.dataflow.qual.SideEffectFree;
/**
- * A renderer for text.
+ * A {@link Renderer} for text.
*
- * {@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances
- * obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s
- * is delegated to a {@link TextOutput}.
+ *
This implementations decodes sample data to {@link Cue} instances. The actual rendering is
+ * delegated to a {@link TextOutput}.
*/
+// TODO: b/289916598 - Add an opt-in method for the legacy subtitle decoding flow, and throw an
+// exception if it's not used and a recognized subtitle MIME type (that isn't
+// application/x-media3-cues) is passed in.
@UnstableApi
public final class TextRenderer extends BaseRenderer implements Callback {
@@ -92,24 +100,31 @@ public final class TextRenderer extends BaseRenderer implements Callback {
private static final int MSG_UPDATE_OUTPUT = 0;
- @Nullable private final Handler outputHandler;
- private final TextOutput output;
- private final SubtitleDecoderFactory decoderFactory;
- private final FormatHolder formatHolder;
+ // Fields used when handling CuesWithTiming objects from application/x-media3-cues samples.
+ private final CueDecoder cueDecoder;
+ private final DecoderInputBuffer cueDecoderInputBuffer;
+ private @MonotonicNonNull CuesResolver cuesResolver;
- private boolean inputStreamEnded;
- private boolean outputStreamEnded;
+ // Fields used when handling Subtitle objects from legacy samples.
+ private final SubtitleDecoderFactory subtitleDecoderFactory;
private boolean waitingForKeyFrame;
private @ReplacementState int decoderReplacementState;
- @Nullable private Format streamFormat;
- @Nullable private SubtitleDecoder decoder;
- @Nullable private SubtitleInputBuffer nextInputBuffer;
+ @Nullable private SubtitleDecoder subtitleDecoder;
+ @Nullable private SubtitleInputBuffer nextSubtitleInputBuffer;
@Nullable private SubtitleOutputBuffer subtitle;
@Nullable private SubtitleOutputBuffer nextSubtitle;
private int nextSubtitleEventIndex;
- private long finalStreamEndPositionUs;
+
+ // Fields used with both CuesWithTiming and Subtitle objects
+ @Nullable private final Handler outputHandler;
+ private final TextOutput output;
+ private final FormatHolder formatHolder;
+ private boolean inputStreamEnded;
+ private boolean outputStreamEnded;
+ @Nullable private Format streamFormat;
private long outputStreamOffsetUs;
private long lastRendererPositionUs;
+ private long finalStreamEndPositionUs;
/**
* @param output The output.
@@ -130,15 +145,20 @@ public TextRenderer(TextOutput output, @Nullable Looper outputLooper) {
* looper associated with the application's main thread, which can be obtained using {@link
* android.app.Activity#getMainLooper()}. Null may be passed if the output should be called
* directly on the player's internal rendering thread.
- * @param decoderFactory A factory from which to obtain {@link SubtitleDecoder} instances.
+ * @param subtitleDecoderFactory A factory from which to obtain {@link SubtitleDecoder} instances.
*/
public TextRenderer(
- TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) {
+ TextOutput output,
+ @Nullable Looper outputLooper,
+ SubtitleDecoderFactory subtitleDecoderFactory) {
super(C.TRACK_TYPE_TEXT);
this.output = checkNotNull(output);
this.outputHandler =
outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this);
- this.decoderFactory = decoderFactory;
+ this.subtitleDecoderFactory = subtitleDecoderFactory;
+ this.cueDecoder = new CueDecoder();
+ this.cueDecoderInputBuffer =
+ new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
formatHolder = new FormatHolder();
finalStreamEndPositionUs = C.TIME_UNSET;
outputStreamOffsetUs = C.TIME_UNSET;
@@ -152,7 +172,7 @@ public String getName() {
@Override
public @Capabilities int supportsFormat(Format format) {
- if (decoderFactory.supportsFormat(format)) {
+ if (isCuesWithTiming(format) || subtitleDecoderFactory.supportsFormat(format)) {
return RendererCapabilities.create(
format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM);
} else if (MimeTypes.isText(format.sampleMimeType)) {
@@ -185,25 +205,37 @@ protected void onStreamChanged(
MediaSource.MediaPeriodId mediaPeriodId) {
outputStreamOffsetUs = offsetUs;
streamFormat = formats[0];
- if (decoder != null) {
- decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
+ if (!isCuesWithTiming(streamFormat)) {
+ if (subtitleDecoder != null) {
+ decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
+ } else {
+ initSubtitleDecoder();
+ }
} else {
- initDecoder();
+ this.cuesResolver =
+ streamFormat.cueReplacementBehavior == Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
+ ? new MergingCuesResolver()
+ : new ReplacingCuesResolver();
}
}
@Override
protected void onPositionReset(long positionUs, boolean joining) {
lastRendererPositionUs = positionUs;
+ if (cuesResolver != null) {
+ cuesResolver.clear();
+ }
clearOutput();
inputStreamEnded = false;
outputStreamEnded = false;
finalStreamEndPositionUs = C.TIME_UNSET;
- if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
- replaceDecoder();
- } else {
- releaseBuffers();
- checkNotNull(decoder).flush();
+ if (streamFormat != null && !isCuesWithTiming(streamFormat)) {
+ if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
+ replaceSubtitleDecoder();
+ } else {
+ releaseSubtitleBuffers();
+ checkNotNull(subtitleDecoder).flush();
+ }
}
}
@@ -211,11 +243,10 @@ protected void onPositionReset(long positionUs, boolean joining) {
@SuppressWarnings("deprecation")
@Override
public void render(long positionUs, long elapsedRealtimeUs) {
- lastRendererPositionUs = positionUs;
if (isCurrentStreamFinal()
&& finalStreamEndPositionUs != C.TIME_UNSET
&& positionUs >= finalStreamEndPositionUs) {
- releaseBuffers();
+ releaseSubtitleBuffers();
outputStreamEnded = true;
}
@@ -223,10 +254,83 @@ public void render(long positionUs, long elapsedRealtimeUs) {
return;
}
+ if (isCuesWithTiming(checkNotNull(streamFormat))) {
+ checkNotNull(cuesResolver);
+ renderFromCuesWithTiming(positionUs);
+ } else {
+ renderFromSubtitles(positionUs);
+ }
+ }
+
+ @RequiresNonNull("this.cuesResolver")
+ private void renderFromCuesWithTiming(long positionUs) {
+ boolean outputNeedsUpdating = readAndDecodeCuesWithTiming(positionUs);
+
+ long nextCueChangeTimeUs = cuesResolver.getNextCueChangeTimeUs(lastRendererPositionUs);
+ if (nextCueChangeTimeUs == C.TIME_END_OF_SOURCE && inputStreamEnded && !outputNeedsUpdating) {
+ outputStreamEnded = true;
+ }
+ if (nextCueChangeTimeUs != C.TIME_END_OF_SOURCE && nextCueChangeTimeUs <= positionUs) {
+ outputNeedsUpdating = true;
+ }
+
+ if (outputNeedsUpdating) {
+ ImmutableList cuesAtTimeUs = cuesResolver.getCuesAtTimeUs(positionUs);
+ long previousCueChangeTimeUs = cuesResolver.getPreviousCueChangeTimeUs(positionUs);
+ updateOutput(new CueGroup(cuesAtTimeUs, getPresentationTimeUs(previousCueChangeTimeUs)));
+ cuesResolver.discardCuesBeforeTimeUs(previousCueChangeTimeUs);
+ }
+ lastRendererPositionUs = positionUs;
+ }
+
+ /**
+ * Tries to {@linkplain #readSource(FormatHolder, DecoderInputBuffer, int) read} a buffer, and if
+ * one is read decodes it to a {@link CuesWithTiming} and adds it to {@link MergingCuesResolver}.
+ *
+ * @return true if a {@link CuesWithTiming} was read that changes what should be on screen now.
+ */
+ @RequiresNonNull("this.cuesResolver")
+ private boolean readAndDecodeCuesWithTiming(long positionUs) {
+ if (inputStreamEnded) {
+ return false;
+ }
+ @ReadDataResult
+ int readResult = readSource(formatHolder, cueDecoderInputBuffer, /* readFlags= */ 0);
+ switch (readResult) {
+ case C.RESULT_BUFFER_READ:
+ if (cueDecoderInputBuffer.isEndOfStream()) {
+ inputStreamEnded = true;
+ return false;
+ }
+ cueDecoderInputBuffer.flip();
+ ByteBuffer cueData = checkNotNull(cueDecoderInputBuffer.data);
+ CuesWithTiming cuesWithTiming =
+ cueDecoder.decode(
+ cueDecoderInputBuffer.timeUs,
+ cueData.array(),
+ cueData.arrayOffset(),
+ cueData.limit());
+ cueDecoderInputBuffer.clear();
+
+ cuesResolver.addCues(cuesWithTiming);
+
+ // Return whether the CuesWithTiming we added to CuesMerger changes the subtitles that
+ // should be on-screen *now*.
+ return cuesWithTiming.startTimeUs <= positionUs
+ && positionUs < cuesWithTiming.startTimeUs + cuesWithTiming.durationUs;
+ case C.RESULT_FORMAT_READ:
+ case C.RESULT_NOTHING_READ:
+ default:
+ return false;
+ }
+ }
+
+ private void renderFromSubtitles(long positionUs) {
+ lastRendererPositionUs = positionUs;
if (nextSubtitle == null) {
- checkNotNull(decoder).setPositionUs(positionUs);
+ checkNotNull(subtitleDecoder).setPositionUs(positionUs);
try {
- nextSubtitle = checkNotNull(decoder).dequeueOutputBuffer();
+ nextSubtitle = checkNotNull(subtitleDecoder).dequeueOutputBuffer();
} catch (SubtitleDecoderException e) {
handleDecoderError(e);
return;
@@ -253,9 +357,9 @@ public void render(long positionUs, long elapsedRealtimeUs) {
if (nextSubtitle.isEndOfStream()) {
if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) {
if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) {
- replaceDecoder();
+ replaceSubtitleDecoder();
} else {
- releaseBuffers();
+ releaseSubtitleBuffers();
outputStreamEnded = true;
}
}
@@ -286,18 +390,18 @@ public void render(long positionUs, long elapsedRealtimeUs) {
try {
while (!inputStreamEnded) {
- @Nullable SubtitleInputBuffer nextInputBuffer = this.nextInputBuffer;
+ @Nullable SubtitleInputBuffer nextInputBuffer = this.nextSubtitleInputBuffer;
if (nextInputBuffer == null) {
- nextInputBuffer = checkNotNull(decoder).dequeueInputBuffer();
+ nextInputBuffer = checkNotNull(subtitleDecoder).dequeueInputBuffer();
if (nextInputBuffer == null) {
return;
}
- this.nextInputBuffer = nextInputBuffer;
+ this.nextSubtitleInputBuffer = nextInputBuffer;
}
if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) {
nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
- checkNotNull(decoder).queueInputBuffer(nextInputBuffer);
- this.nextInputBuffer = null;
+ checkNotNull(subtitleDecoder).queueInputBuffer(nextInputBuffer);
+ this.nextSubtitleInputBuffer = null;
decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM;
return;
}
@@ -321,8 +425,8 @@ public void render(long positionUs, long elapsedRealtimeUs) {
if (nextInputBuffer.timeUs < getLastResetPositionUs()) {
nextInputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
}
- checkNotNull(decoder).queueInputBuffer(nextInputBuffer);
- this.nextInputBuffer = null;
+ checkNotNull(subtitleDecoder).queueInputBuffer(nextInputBuffer);
+ this.nextSubtitleInputBuffer = null;
}
} else if (result == C.RESULT_NOTHING_READ) {
return;
@@ -340,7 +444,9 @@ protected void onDisabled() {
clearOutput();
outputStreamOffsetUs = C.TIME_UNSET;
lastRendererPositionUs = C.TIME_UNSET;
- releaseDecoder();
+ if (subtitleDecoder != null) {
+ releaseSubtitleDecoder();
+ }
}
@Override
@@ -355,8 +461,8 @@ public boolean isReady() {
return true;
}
- private void releaseBuffers() {
- nextInputBuffer = null;
+ private void releaseSubtitleBuffers() {
+ nextSubtitleInputBuffer = null;
nextSubtitleEventIndex = C.INDEX_UNSET;
if (subtitle != null) {
subtitle.release();
@@ -368,21 +474,21 @@ private void releaseBuffers() {
}
}
- private void releaseDecoder() {
- releaseBuffers();
- checkNotNull(decoder).release();
- decoder = null;
+ private void releaseSubtitleDecoder() {
+ releaseSubtitleBuffers();
+ checkNotNull(subtitleDecoder).release();
+ subtitleDecoder = null;
decoderReplacementState = REPLACEMENT_STATE_NONE;
}
- private void initDecoder() {
+ private void initSubtitleDecoder() {
waitingForKeyFrame = true;
- decoder = decoderFactory.createDecoder(checkNotNull(streamFormat));
+ subtitleDecoder = subtitleDecoderFactory.createDecoder(checkNotNull(streamFormat));
}
- private void replaceDecoder() {
- releaseDecoder();
- initDecoder();
+ private void replaceSubtitleDecoder() {
+ releaseSubtitleDecoder();
+ initSubtitleDecoder();
}
private long getNextEventTime() {
@@ -425,7 +531,7 @@ private void invokeUpdateOutputInternal(CueGroup cueGroup) {
}
/**
- * Called when {@link #decoder} throws an exception, so it can be logged and playback can
+ * Called when {@link #subtitleDecoder} throws an exception, so it can be logged and playback can
* continue.
*
* Logs {@code e} and resets state to allow decoding the next sample.
@@ -433,7 +539,7 @@ private void invokeUpdateOutputInternal(CueGroup cueGroup) {
private void handleDecoderError(SubtitleDecoderException e) {
Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e);
clearOutput();
- replaceDecoder();
+ replaceSubtitleDecoder();
}
@RequiresNonNull("subtitle")
@@ -456,4 +562,9 @@ private long getPresentationTimeUs(long positionUs) {
return positionUs - outputStreamOffsetUs;
}
+
+ /** Returns whether {@link Format#sampleMimeType} is {@link MimeTypes#APPLICATION_MEDIA3_CUES}. */
+ private static boolean isCuesWithTiming(Format format) {
+ return Objects.equals(format.sampleMimeType, MimeTypes.APPLICATION_MEDIA3_CUES);
+ }
}
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/CuesListTestUtil.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/CuesListTestUtil.java
new file mode 100644
index 00000000000..f8de8a3782b
--- /dev/null
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/CuesListTestUtil.java
@@ -0,0 +1,77 @@
+/*
+ * 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 static com.google.common.truth.Truth.assertThat;
+
+import androidx.media3.common.C;
+import com.google.common.collect.Lists;
+
+/* package */ class CuesListTestUtil {
+
+ private CuesListTestUtil() {}
+
+ public static void assertNoCuesBetween(
+ CuesResolver cuesResolver, long startTimeUs, long endTimeUs) {
+ assertCueTextBetween(cuesResolver, startTimeUs, endTimeUs);
+ }
+
+ public static void assertCueTextBetween(
+ CuesResolver cuesResolver, long startTimeUs, long endTimeUs, String... expectedCueTexts) {
+ checkArgument(startTimeUs != C.TIME_UNSET);
+ checkArgument(endTimeUs != C.TIME_END_OF_SOURCE);
+
+ assertThat(Lists.transform(cuesResolver.getCuesAtTimeUs(startTimeUs), c -> c.text))
+ .containsExactlyElementsIn(expectedCueTexts)
+ .inOrder();
+ assertThat(Lists.transform(cuesResolver.getCuesAtTimeUs(startTimeUs + 1), c -> c.text))
+ .containsExactlyElementsIn(expectedCueTexts)
+ .inOrder();
+ assertThat(Lists.transform(cuesResolver.getCuesAtTimeUs(endTimeUs - 1), c -> c.text))
+ .containsExactlyElementsIn(expectedCueTexts)
+ .inOrder();
+
+ assertThat(cuesResolver.getPreviousCueChangeTimeUs(startTimeUs)).isEqualTo(startTimeUs);
+ assertThat(cuesResolver.getPreviousCueChangeTimeUs(endTimeUs - 1)).isEqualTo(startTimeUs);
+
+ assertThat(cuesResolver.getNextCueChangeTimeUs(startTimeUs)).isEqualTo(endTimeUs);
+ }
+
+ public static void assertCueTextUntilEnd(
+ CuesResolver cuesResolver, long startTimeUs, String... expectedCueTexts) {
+ assertThat(Lists.transform(cuesResolver.getCuesAtTimeUs(startTimeUs), c -> c.text))
+ .containsExactlyElementsIn(expectedCueTexts)
+ .inOrder();
+ assertThat(Lists.transform(cuesResolver.getCuesAtTimeUs(startTimeUs + 1), c -> c.text))
+ .containsExactlyElementsIn(expectedCueTexts)
+ .inOrder();
+ assertThat(cuesResolver.getPreviousCueChangeTimeUs(startTimeUs)).isEqualTo(startTimeUs);
+ assertThat(cuesResolver.getNextCueChangeTimeUs(startTimeUs)).isEqualTo(C.TIME_END_OF_SOURCE);
+ }
+
+ public static void assertCuesStartAt(CuesResolver cuesResolver, long timeUs) {
+ assertThat(cuesResolver.getCuesAtTimeUs(timeUs - 1)).isEmpty();
+ assertThat(cuesResolver.getPreviousCueChangeTimeUs(timeUs - 1)).isEqualTo(C.TIME_UNSET);
+ assertThat(cuesResolver.getNextCueChangeTimeUs(timeUs - 1)).isEqualTo(timeUs);
+ }
+
+ public static void assertCuesEndAt(CuesResolver cuesResolver, long timeUs) {
+ assertThat(cuesResolver.getCuesAtTimeUs(timeUs)).isEmpty();
+ assertThat(cuesResolver.getPreviousCueChangeTimeUs(timeUs)).isEqualTo(timeUs);
+ assertThat(cuesResolver.getNextCueChangeTimeUs(timeUs)).isEqualTo(C.TIME_END_OF_SOURCE);
+ }
+}
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoderTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoderTest.java
deleted file mode 100644
index b220e233026..00000000000
--- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ExoplayerCuesDecoderTest.java
+++ /dev/null
@@ -1,228 +0,0 @@
-/*
- * Copyright 2021 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.text;
-
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
-import androidx.media3.common.C;
-import androidx.media3.common.text.Cue;
-import androidx.media3.extractor.text.CueEncoder;
-import androidx.media3.extractor.text.SubtitleDecoderException;
-import androidx.media3.extractor.text.SubtitleInputBuffer;
-import androidx.media3.extractor.text.SubtitleOutputBuffer;
-import androidx.test.ext.junit.runners.AndroidJUnit4;
-import com.google.common.collect.ImmutableList;
-import java.util.ArrayList;
-import java.util.List;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-
-/** Test for {@link ExoplayerCuesDecoder} */
-@RunWith(AndroidJUnit4.class)
-public class ExoplayerCuesDecoderTest {
- private ExoplayerCuesDecoder decoder;
- private static final byte[] ENCODED_CUES_WITH_DURATION =
- new CueEncoder()
- .encode(
- ImmutableList.of(new Cue.Builder().setText("text").build()), /* durationUs= */ 2000);
- private static final byte[] ENCODED_CUES_WITHOUT_DURATION =
- new CueEncoder()
- .encode(
- ImmutableList.of(new Cue.Builder().setText("other text").build()),
- /* durationUs= */ C.TIME_UNSET);
-
- @Before
- public void setUp() {
- decoder = new ExoplayerCuesDecoder();
- }
-
- @After
- public void tearDown() {
- decoder.release();
- }
-
- @Test
- public void decode_withDuration() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
-
- assertThat(outputBuffer.getEventTimeCount()).isEqualTo(2);
- assertThat(outputBuffer.getEventTime(0)).isEqualTo(1000);
- assertThat(outputBuffer.getCues(/* timeUs= */ 999)).isEmpty();
- assertThat(outputBuffer.getCues(/* timeUs= */ 1001)).hasSize(1);
- assertThat(outputBuffer.getCues(/* timeUs= */ 1000)).hasSize(1);
- assertThat(outputBuffer.getCues(/* timeUs= */ 1000).get(0).text.toString()).isEqualTo("text");
- assertThat(outputBuffer.getEventTime(1)).isEqualTo(3000);
- assertThat(outputBuffer.getCues(/* timeUs= */ 3000)).isEmpty();
-
- outputBuffer.release();
- }
-
- @Test
- public void decode_withoutDuration() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITHOUT_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
-
- assertThat(outputBuffer.getEventTimeCount()).isEqualTo(1);
- assertThat(outputBuffer.getEventTime(0)).isEqualTo(1000);
- assertThat(outputBuffer.getCues(/* timeUs= */ 999)).isEmpty();
- assertThat(outputBuffer.getCues(/* timeUs= */ 1001)).hasSize(1);
- assertThat(outputBuffer.getCues(/* timeUs= */ 1000)).hasSize(1);
- assertThat(outputBuffer.getCues(/* timeUs= */ 1000).get(0).text.toString())
- .isEqualTo("other text");
-
- outputBuffer.release();
- }
-
- @Test
- public void dequeueOutputBuffer_returnsNullWhenInputBufferIsNotQueued() throws Exception {
- // Returns null before input buffer has been dequeued
- assertThat(decoder.dequeueOutputBuffer()).isNull();
-
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
-
- // Returns null before input has been queued
- assertThat(decoder.dequeueOutputBuffer()).isNull();
-
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
-
- // Returns buffer when the input buffer is queued and output buffer is available
- assertThat(decoder.dequeueOutputBuffer()).isNotNull();
-
- // Returns null before next input buffer is queued
- assertThat(decoder.dequeueOutputBuffer()).isNull();
- }
-
- @Test
- public void dequeueOutputBuffer_releasedOutputAndQueuedNextInput_returnsOutputBuffer()
- throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
- exhaustAllOutputBuffers(decoder);
-
- assertThat(decoder.dequeueOutputBuffer()).isNull();
- outputBuffer.release();
- assertThat(decoder.dequeueOutputBuffer()).isNotNull();
- }
-
- @Test
- public void dequeueOutputBuffer_queuedOnEndOfStreamInputBuffer_returnsEndOfStreamOutputBuffer()
- throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
-
- assertThat(outputBuffer.isEndOfStream()).isTrue();
- }
-
- @Test
- public void dequeueInputBuffer_withQueuedInput_returnsNull() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
-
- assertThat(decoder.dequeueInputBuffer()).isNull();
- }
-
- @Test
- public void queueInputBuffer_queueingInputBufferThatDoesNotComeFromDecoder_fails() {
- assertThrows(
- IllegalStateException.class, () -> decoder.queueInputBuffer(new SubtitleInputBuffer()));
- }
-
- @Test
- public void queueInputBuffer_calledTwice_fails() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- decoder.queueInputBuffer(inputBuffer);
-
- assertThrows(IllegalStateException.class, () -> decoder.queueInputBuffer(inputBuffer));
- }
-
- @Test
- public void releaseOutputBuffer_calledTwice_fails() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
- outputBuffer.release();
-
- assertThrows(IllegalStateException.class, outputBuffer::release);
- }
-
- @Test
- public void flush_doesNotInfluenceOutputBufferAvailability() throws Exception {
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- SubtitleOutputBuffer outputBuffer = decoder.dequeueOutputBuffer();
- assertThat(outputBuffer).isNotNull();
- exhaustAllOutputBuffers(decoder);
- decoder.flush();
- inputBuffer = decoder.dequeueInputBuffer();
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
-
- assertThat(decoder.dequeueOutputBuffer()).isNull();
- }
-
- @Test
- public void flush_makesAllInputBuffersAvailable() throws Exception {
- List inputBuffers = new ArrayList<>();
-
- SubtitleInputBuffer inputBuffer = decoder.dequeueInputBuffer();
- while (inputBuffer != null) {
- inputBuffers.add(inputBuffer);
- inputBuffer = decoder.dequeueInputBuffer();
- }
- for (int i = 0; i < inputBuffers.size(); i++) {
- writeDataToInputBuffer(inputBuffers.get(i), /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffers.get(i));
- }
- decoder.flush();
-
- for (int i = 0; i < inputBuffers.size(); i++) {
- assertThat(decoder.dequeueInputBuffer().data.position()).isEqualTo(0);
- }
- }
-
- private void exhaustAllOutputBuffers(ExoplayerCuesDecoder decoder)
- throws SubtitleDecoderException {
- SubtitleInputBuffer inputBuffer;
- do {
- inputBuffer = decoder.dequeueInputBuffer();
- if (inputBuffer != null) {
- writeDataToInputBuffer(inputBuffer, /* timeUs= */ 1000, ENCODED_CUES_WITH_DURATION);
- decoder.queueInputBuffer(inputBuffer);
- }
- } while (decoder.dequeueOutputBuffer() != null);
- }
-
- private void writeDataToInputBuffer(SubtitleInputBuffer inputBuffer, long timeUs, byte[] data) {
- inputBuffer.timeUs = timeUs;
- inputBuffer.ensureSpaceForWrite(data.length);
- inputBuffer.data.put(data);
- }
-}
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/MergingCuesResolverTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/MergingCuesResolverTest.java
new file mode 100644
index 00000000000..58070713177
--- /dev/null
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/MergingCuesResolverTest.java
@@ -0,0 +1,208 @@
+/*
+ * 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.exoplayer.text.CuesListTestUtil.assertCueTextBetween;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertCuesEndAt;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertCuesStartAt;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertNoCuesBetween;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import androidx.media3.common.C;
+import androidx.media3.common.text.Cue;
+import androidx.media3.extractor.text.CuesWithTiming;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link MergingCuesResolver}. */
+@RunWith(AndroidJUnit4.class)
+public final class MergingCuesResolverTest {
+
+ private static final ImmutableList FIRST_CUES =
+ ImmutableList.of(new Cue.Builder().setText("first cue").build());
+ public static final ImmutableList SECOND_CUES =
+ ImmutableList.of(
+ new Cue.Builder().setText("second group: cue1").build(),
+ new Cue.Builder().setText("second group: cue2").build());
+ public static final ImmutableList THIRD_CUES =
+ ImmutableList.of(new Cue.Builder().setText("third cue").build());
+
+ @Test
+ public void empty() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+
+ assertThat(mergingCuesResolver.getPreviousCueChangeTimeUs(999_999_999)).isEqualTo(C.TIME_UNSET);
+ assertThat(mergingCuesResolver.getNextCueChangeTimeUs(0)).isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(mergingCuesResolver.getCuesAtTimeUs(0)).isEmpty();
+ }
+
+ @Test
+ public void nonOverlappingCues() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+
+ // Reverse the addCues call to check everything still works (it should).
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+
+ assertCuesStartAt(mergingCuesResolver, 3_000_000);
+
+ assertCueTextBetween(mergingCuesResolver, 3_000_000, 5_000_000, "first cue");
+ assertNoCuesBetween(mergingCuesResolver, 5_000_000, 6_000_000);
+ assertCueTextBetween(
+ mergingCuesResolver, 6_000_000, 7_000_000, "second group: cue1", "second group: cue2");
+ assertCuesEndAt(mergingCuesResolver, 7_000_000);
+ }
+
+ @Test
+ public void overlappingCues() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 3_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 2_000_000, /* durationUs= */ 4_000_000);
+
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(mergingCuesResolver, 1_000_000);
+ assertCueTextBetween(mergingCuesResolver, 1_000_000, 2_000_000, "first cue");
+ // secondCuesWithTiming has a later start time (despite longer duration), so should appear later
+ // in the list.
+ assertCueTextBetween(
+ mergingCuesResolver,
+ 2_000_000,
+ 4_000_000,
+ "first cue",
+ "second group: cue1",
+ "second group: cue2");
+ assertCueTextBetween(
+ mergingCuesResolver, 4_000_000, 6_000_000, "second group: cue1", "second group: cue2");
+ assertCuesEndAt(mergingCuesResolver, 6_000_000);
+ }
+
+ @Test
+ public void overlappingCues_matchingStartTimes() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 4_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 3_000_000);
+
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(mergingCuesResolver, 1_000_000);
+ // secondCuesWithTiming has a shorter duration than firstCuesWithTiming, so should appear later
+ // in the list.
+ assertCueTextBetween(
+ mergingCuesResolver,
+ 1_000_000,
+ 4_000_000,
+ "first cue",
+ "second group: cue1",
+ "second group: cue2");
+ assertCueTextBetween(mergingCuesResolver, 4_000_000, 5_000_000, "first cue");
+ assertCuesEndAt(mergingCuesResolver, 5_000_000);
+ }
+
+ @Test
+ public void overlappingCues_matchingEndTimes() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 4_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 2_000_000, /* durationUs= */ 3_000_000);
+
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(mergingCuesResolver, 1_000_000);
+ // secondCuesWithTiming has a shorter duration than firstCuesWithTiming, so should appear later
+ // in the list.
+ assertCueTextBetween(mergingCuesResolver, 1_000_000, 2_000_000, "first cue");
+ assertCueTextBetween(
+ mergingCuesResolver,
+ 2_000_000,
+ 5_000_000,
+ "first cue",
+ "second group: cue1",
+ "second group: cue2");
+ assertCuesEndAt(mergingCuesResolver, 5_000_000);
+ }
+
+ @Test
+ public void unsetDuration_unsupported() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming cuesWithTiming =
+ new CuesWithTiming(
+ FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ C.TIME_UNSET);
+
+ assertThrows(IllegalArgumentException.class, () -> mergingCuesResolver.addCues(cuesWithTiming));
+ }
+
+ @Test
+ public void discardCuesBeforeTimeUs() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+ CuesWithTiming thirdCuesWithTiming =
+ new CuesWithTiming(THIRD_CUES, /* startTimeUs= */ 8_000_000, /* durationUs= */ 4_000_000);
+
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+ mergingCuesResolver.addCues(thirdCuesWithTiming);
+
+ // Remove only firstCuesWithTiming (secondCuesWithTiming should be kept because it ends after
+ // this time).
+ mergingCuesResolver.discardCuesBeforeTimeUs(6_500_000);
+
+ // Query with a time that *should* be inside firstCuesWithTiming, but it's been removed.
+ assertThat(mergingCuesResolver.getCuesAtTimeUs(4_999_990)).isEmpty();
+ assertThat(mergingCuesResolver.getPreviousCueChangeTimeUs(4_999_990)).isEqualTo(C.TIME_UNSET);
+ assertThat(mergingCuesResolver.getNextCueChangeTimeUs(4_999_990)).isEqualTo(6_000_000);
+ }
+
+ @Test
+ public void clear_clearsAllCues() {
+ MergingCuesResolver mergingCuesResolver = new MergingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+ CuesWithTiming thirdCuesWithTiming =
+ new CuesWithTiming(THIRD_CUES, /* startTimeUs= */ 8_000_000, /* durationUs= */ 4_000_000);
+
+ mergingCuesResolver.addCues(firstCuesWithTiming);
+ mergingCuesResolver.addCues(secondCuesWithTiming);
+ mergingCuesResolver.addCues(thirdCuesWithTiming);
+
+ mergingCuesResolver.clear();
+
+ assertThat(mergingCuesResolver.getPreviousCueChangeTimeUs(999_999_999)).isEqualTo(C.TIME_UNSET);
+ assertThat(mergingCuesResolver.getNextCueChangeTimeUs(0)).isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(mergingCuesResolver.getCuesAtTimeUs(0)).isEmpty();
+ }
+}
diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ReplacingCuesResolverTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ReplacingCuesResolverTest.java
new file mode 100644
index 00000000000..0f4cbf99485
--- /dev/null
+++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/ReplacingCuesResolverTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.exoplayer.text.CuesListTestUtil.assertCueTextBetween;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertCueTextUntilEnd;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertCuesEndAt;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertCuesStartAt;
+import static androidx.media3.exoplayer.text.CuesListTestUtil.assertNoCuesBetween;
+import static com.google.common.truth.Truth.assertThat;
+
+import androidx.media3.common.C;
+import androidx.media3.common.text.Cue;
+import androidx.media3.extractor.text.CuesWithTiming;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import com.google.common.collect.ImmutableList;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Tests for {@link ReplacingCuesResolver}. */
+@RunWith(AndroidJUnit4.class)
+public final class ReplacingCuesResolverTest {
+
+ private static final ImmutableList FIRST_CUES =
+ ImmutableList.of(new Cue.Builder().setText("first cue").build());
+ public static final ImmutableList SECOND_CUES =
+ ImmutableList.of(
+ new Cue.Builder().setText("second group: cue1").build(),
+ new Cue.Builder().setText("second group: cue2").build());
+ public static final ImmutableList THIRD_CUES =
+ ImmutableList.of(new Cue.Builder().setText("third cue").build());
+
+ @Test
+ public void empty() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+
+ assertThat(replacingCuesResolver.getPreviousCueChangeTimeUs(999_999_999))
+ .isEqualTo(C.TIME_UNSET);
+ assertThat(replacingCuesResolver.getNextCueChangeTimeUs(0)).isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(replacingCuesResolver.getCuesAtTimeUs(0)).isEmpty();
+ }
+
+ @Test
+ public void unsetDuration() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming cuesWithTiming =
+ new CuesWithTiming(
+ FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ C.TIME_UNSET);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(
+ SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ C.TIME_UNSET);
+
+ // Reverse the addCues call to check everything still works (it should).
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+ replacingCuesResolver.addCues(cuesWithTiming);
+
+ assertCuesStartAt(replacingCuesResolver, 3_000_000);
+ assertCueTextBetween(replacingCuesResolver, 3_000_000, 6_000_000, "first cue");
+ assertCueTextUntilEnd(
+ replacingCuesResolver, 6_000_000, "second group: cue1", "second group: cue2");
+ }
+
+ @Test
+ public void nonOverlappingCues() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+
+ replacingCuesResolver.addCues(firstCuesWithTiming);
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(replacingCuesResolver, 3_000_000);
+
+ assertCueTextBetween(replacingCuesResolver, 3_000_000, 5_000_000, "first cue");
+ assertNoCuesBetween(replacingCuesResolver, 5_000_000, 6_000_000);
+ assertCueTextBetween(
+ replacingCuesResolver, 6_000_000, 7_000_000, "second group: cue1", "second group: cue2");
+ assertCuesEndAt(replacingCuesResolver, 7_000_000);
+ }
+
+ @Test
+ public void overlappingCues_secondReplacesFirst() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 3_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 2_000_000, /* durationUs= */ 4_000_000);
+
+ replacingCuesResolver.addCues(firstCuesWithTiming);
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(replacingCuesResolver, 1_000_000);
+ assertCueTextBetween(replacingCuesResolver, 1_000_000, 2_000_000, "first cue");
+ assertCueTextBetween(
+ replacingCuesResolver, 2_000_000, 6_000_000, "second group: cue1", "second group: cue2");
+ assertCuesEndAt(replacingCuesResolver, 6_000_000);
+ }
+
+ @Test
+ public void overlappingCues_matchingStartTimes_onlySecondEmitted() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 4_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 1_000_000, /* durationUs= */ 3_000_000);
+
+ replacingCuesResolver.addCues(firstCuesWithTiming);
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+
+ assertCuesStartAt(replacingCuesResolver, 1_000_000);
+ assertCueTextBetween(
+ replacingCuesResolver, 1_000_000, 4_000_000, "second group: cue1", "second group: cue2");
+ assertCuesEndAt(replacingCuesResolver, 4_000_000);
+ }
+
+ @Test
+ public void discardCuesBeforeTimeUs() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+
+ replacingCuesResolver.addCues(firstCuesWithTiming);
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+
+ // Remove firstCuesWithTiming
+ replacingCuesResolver.discardCuesBeforeTimeUs(5_500_000);
+
+ // Query with a time that *should* be inside firstCuesWithTiming, but it's been removed.
+ assertThat(replacingCuesResolver.getCuesAtTimeUs(4_999_990)).isEmpty();
+ assertThat(replacingCuesResolver.getPreviousCueChangeTimeUs(4_999_990)).isEqualTo(C.TIME_UNSET);
+ assertThat(replacingCuesResolver.getNextCueChangeTimeUs(4_999_990)).isEqualTo(6_000_000);
+ }
+
+ @Test
+ public void clear_clearsAllCues() {
+ ReplacingCuesResolver replacingCuesResolver = new ReplacingCuesResolver();
+ CuesWithTiming firstCuesWithTiming =
+ new CuesWithTiming(FIRST_CUES, /* startTimeUs= */ 3_000_000, /* durationUs= */ 2_000_000);
+ CuesWithTiming secondCuesWithTiming =
+ new CuesWithTiming(SECOND_CUES, /* startTimeUs= */ 6_000_000, /* durationUs= */ 1_000_000);
+ CuesWithTiming thirdCuesWithTiming =
+ new CuesWithTiming(THIRD_CUES, /* startTimeUs= */ 8_000_000, /* durationUs= */ 4_000_000);
+
+ replacingCuesResolver.addCues(firstCuesWithTiming);
+ replacingCuesResolver.addCues(secondCuesWithTiming);
+ replacingCuesResolver.addCues(thirdCuesWithTiming);
+
+ replacingCuesResolver.clear();
+
+ assertThat(replacingCuesResolver.getPreviousCueChangeTimeUs(999_999_999))
+ .isEqualTo(C.TIME_UNSET);
+ assertThat(replacingCuesResolver.getNextCueChangeTimeUs(0)).isEqualTo(C.TIME_END_OF_SOURCE);
+ assertThat(replacingCuesResolver.getCuesAtTimeUs(0)).isEmpty();
+ }
+}
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueDecoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueDecoder.java
index f8a28efc791..fcda2ed1f5b 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueDecoder.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueDecoder.java
@@ -43,8 +43,22 @@ public final class CueDecoder {
* @return Decoded {@link CuesWithTiming} instance.
*/
public CuesWithTiming decode(long startTimeUs, byte[] bytes) {
+ return decode(startTimeUs, bytes, /* offset= */ 0, bytes.length);
+ }
+
+ /**
+ * Decodes a byte array into a {@link CuesWithTiming} instance.
+ *
+ * @param startTimeUs The value for {@link CuesWithTiming#startTimeUs} (this is not encoded in
+ * {@code bytes}).
+ * @param bytes Byte array containing data produced by {@link CueEncoder#encode(List, long)}
+ * @param offset The start index of cue data in {@code bytes}.
+ * @param length The length of cue data in {@code bytes}.
+ * @return Decoded {@link CuesWithTiming} instance.
+ */
+ public CuesWithTiming decode(long startTimeUs, byte[] bytes, int offset, int length) {
Parcel parcel = Parcel.obtain();
- parcel.unmarshall(bytes, 0, bytes.length);
+ parcel.unmarshall(bytes, offset, length);
parcel.setDataPosition(0);
Bundle bundle = parcel.readBundle(Bundle.class.getClassLoader());
parcel.recycle();
diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueEncoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueEncoder.java
index ed6d1a2f521..8519b8b32e1 100644
--- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueEncoder.java
+++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/CueEncoder.java
@@ -29,7 +29,7 @@ public final class CueEncoder {
/**
* Encodes a {@link Cue} list and duration to a byte array that can be decoded by {@link
- * CueDecoder#decode(long, byte[])}.
+ * CueDecoder#decode}.
*
* @param cues Cues to be encoded.
* @param durationUs Duration to be encoded, in microseconds.
diff --git a/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_srt.mkv.dump b/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_srt.mkv.dump
index 96f68da9c69..9ba0e55a392 100644
--- a/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_srt.mkv.dump
+++ b/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_srt.mkv.dump
@@ -533,11 +533,27 @@ TextOutput:
Subtitle[2]:
presentationTimeUs = 150000
Cue[0]:
+ text = First subtitle - end overlaps second
+ Cue[1]:
text = Third subtitle - fully encompasses second
Subtitle[3]:
presentationTimeUs = 200000
Cue[0]:
+ text = First subtitle - end overlaps second
+ Cue[1]:
+ text = Third subtitle - fully encompasses second
+ Cue[2]:
text = Second subtitle - beginning overlaps first
Subtitle[4]:
+ presentationTimeUs = 330000
+ Cue[0]:
+ text = Third subtitle - fully encompasses second
+ Cue[1]:
+ text = Second subtitle - beginning overlaps first
+ Subtitle[5]:
presentationTimeUs = 450000
+ Cue[0]:
+ text = Third subtitle - fully encompasses second
+ Subtitle[6]:
+ presentationTimeUs = 550000
Cues = []
diff --git a/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_ssa_subtitles.mkv.dump b/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_ssa_subtitles.mkv.dump
index 645ee825efb..3a1d2420657 100644
--- a/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_ssa_subtitles.mkv.dump
+++ b/libraries/test_data/src/test/assets/playbackdumps/mkv/sample_with_overlapping_ssa_subtitles.mkv.dump
@@ -534,13 +534,35 @@ TextOutput:
Subtitle[2]:
presentationTimeUs = 150000
Cue[0]:
+ text = First subtitle - end overlaps second
+ lineType = 0
+ Cue[1]:
text = Third subtitle - fully encompasses second
lineType = 0
Subtitle[3]:
presentationTimeUs = 200000
Cue[0]:
+ text = First subtitle - end overlaps second
+ lineType = 0
+ Cue[1]:
+ text = Third subtitle - fully encompasses second
+ lineType = 0
+ Cue[2]:
text = Second subtitle - beginning overlaps first
lineType = 0
Subtitle[4]:
+ presentationTimeUs = 330000
+ Cue[0]:
+ text = Third subtitle - fully encompasses second
+ lineType = 0
+ Cue[1]:
+ text = Second subtitle - beginning overlaps first
+ lineType = 0
+ Subtitle[5]:
presentationTimeUs = 450000
+ Cue[0]:
+ text = Third subtitle - fully encompasses second
+ lineType = 0
+ Subtitle[6]:
+ presentationTimeUs = 550000
Cues = []