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 { *

*/ 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 = []