Update TextRenderer to handle CuesWithTiming instances directly

The existing `Subtitle` handling code is left intact to support the
legacy post-`SampleQueue` decoding path for now.

This also includes full support for merging overlapping `CuesWithTiming`
instances, which explains the test dump file changes, and which should
resolve the following issues (if used with the
decoder-before-`SampleQueue` subtitle logic added in
5d453fcf37):

* Issue: google/ExoPlayer#10295
* Issue: google/ExoPlayer#4794

It should also help resolve Issue: androidx/media#288, but that will also require
some changes in the DASH module to enable pre-`SampleQueue` subtitle
parsing (which should happen soon).

#minor-release

PiperOrigin-RevId: 571021417
This commit is contained in:
ibaker 2023-10-05 08:14:27 -07:00 committed by Copybara-Service
parent 49b1e0bbc2
commit 002ee0555d
15 changed files with 1051 additions and 447 deletions

View File

@ -13,6 +13,9 @@
`AudioSink.Listener`. `AudioSink.Listener`.
* Video: * Video:
* Text: * Text:
* Remove `ExoplayerCuesDecoder`. Text tracks with `sampleMimeType =
application/x-media3-cues` are now directly handled by `TextRenderer`
without needing a `SubtitleDecoder` instance.
* Metadata: * Metadata:
* `MetadataDecoder.decode` will no longer be called for "decode-only" * `MetadataDecoder.decode` will no longer be called for "decode-only"
samples as the implementation must return null anyway. samples as the implementation must return null anyway.

View File

@ -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.
*
* <p>It also exposes methods for querying when the next and previous change in subtitles is.
*
* <p>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<Cue> 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}.
*
* <p>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).
*
* <p>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();
}

View File

@ -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<SubtitleOutputBuffer> 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);
}
}

View File

@ -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.
*
* <p>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}.
*
* <p>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<CuesWithTiming> CUES_DISPLAY_PRIORITY_COMPARATOR =
Ordering.<Long>natural()
.onResultOf((CuesWithTiming c) -> c.startTimeUs)
.compound(
Ordering.<Long>natural().reverse().onResultOf((CuesWithTiming c) -> c.durationUs));
/** Sorted by {@link CuesWithTiming#startTimeUs} ascending. */
private final List<CuesWithTiming> 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<Cue> getCuesAtTimeUs(long timeUs) {
if (cuesWithTimingList.isEmpty() || timeUs < cuesWithTimingList.get(0).startTimeUs) {
return ImmutableList.of();
}
List<CuesWithTiming> 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<CuesWithTiming> sortedResult =
ImmutableList.sortedCopyOf(CUES_DISPLAY_PRIORITY_COMPARATOR, visibleCues);
ImmutableList.Builder<Cue> 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();
}
}

View File

@ -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.
*
* <p>Each {@link CuesWithTiming} is used from its {@linkplain CuesWithTiming#startTimeUs start
* time} to its {@linkplain CuesWithTiming#endTimeUs end time}, or the start time of the next
* instance if sooner (or the end time is {@link C#TIME_UNSET}).
*
* <p>If the last {@link CuesWithTiming} has an {@linkplain C#TIME_UNSET unset} end time, its used
* until the end of the playback.
*/
// TODO: b/181312195 - Add memoization
/* package */ final class ReplacingCuesResolver implements CuesResolver {
/** Sorted by {@link CuesWithTiming#startTimeUs} ascending. */
private final ArrayList<CuesWithTiming> cuesWithTimingList;
public ReplacingCuesResolver() {
cuesWithTimingList = new ArrayList<>();
}
@Override
public 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<Cue> getCuesAtTimeUs(long timeUs) {
int indexStartingAfterTimeUs = getIndexOfCuesStartingAfter(timeUs);
if (indexStartingAfterTimeUs == 0) {
// Either the first cue starts after timeUs, or the cues list is empty.
return ImmutableList.of();
}
CuesWithTiming cues = cuesWithTimingList.get(indexStartingAfterTimeUs - 1);
return cues.endTimeUs == C.TIME_UNSET || timeUs < cues.endTimeUs
? cues.cues
: ImmutableList.of();
}
@Override
public void discardCuesBeforeTimeUs(long timeUs) {
int indexToDiscardTo = getIndexOfCuesStartingAfter(timeUs);
if (indexToDiscardTo > 0) {
cuesWithTimingList.subList(0, indexToDiscardTo).clear();
}
}
@Override
public long getPreviousCueChangeTimeUs(long timeUs) {
if (cuesWithTimingList.isEmpty() || timeUs < cuesWithTimingList.get(0).startTimeUs) {
return C.TIME_UNSET;
}
for (int i = 1; i < cuesWithTimingList.size(); i++) {
long nextCuesStartTimeUs = cuesWithTimingList.get(i).startTimeUs;
if (timeUs == nextCuesStartTimeUs) {
return nextCuesStartTimeUs;
}
if (timeUs < nextCuesStartTimeUs) {
CuesWithTiming cues = cuesWithTimingList.get(i - 1);
return cues.endTimeUs != C.TIME_UNSET && cues.endTimeUs <= timeUs
? cues.endTimeUs
: cues.startTimeUs;
}
}
CuesWithTiming lastCues = Iterables.getLast(cuesWithTimingList);
return lastCues.endTimeUs == C.TIME_UNSET || timeUs < lastCues.endTimeUs
? lastCues.startTimeUs
: lastCues.endTimeUs;
}
@Override
public long getNextCueChangeTimeUs(long timeUs) {
if (cuesWithTimingList.isEmpty()) {
return C.TIME_END_OF_SOURCE;
}
if (timeUs < cuesWithTimingList.get(0).startTimeUs) {
return cuesWithTimingList.get(0).startTimeUs;
}
for (int i = 1; i < cuesWithTimingList.size(); i++) {
CuesWithTiming cues = cuesWithTimingList.get(i);
if (timeUs < cues.startTimeUs) {
CuesWithTiming previousCues = cuesWithTimingList.get(i - 1);
return previousCues.endTimeUs != C.TIME_UNSET
&& previousCues.endTimeUs > timeUs
&& previousCues.endTimeUs < cues.startTimeUs
? previousCues.endTimeUs
: cues.startTimeUs;
}
}
CuesWithTiming lastCues = Iterables.getLast(cuesWithTimingList);
return lastCues.endTimeUs != C.TIME_UNSET && timeUs < lastCues.endTimeUs
? lastCues.endTimeUs
: C.TIME_END_OF_SOURCE;
}
@Override
public void clear() {
cuesWithTimingList.clear();
}
/**
* Returns the index of the first {@link CuesWithTiming} in {@link #cuesWithTimingList} where
* {@link CuesWithTiming#startTimeUs} is strictly less than {@code timeUs}.
*
* <p>Returns the size of {@link #cuesWithTimingList} if all cues are before timeUs
*/
private int getIndexOfCuesStartingAfter(long timeUs) {
for (int i = 0; i < cuesWithTimingList.size(); i++) {
if (timeUs < cuesWithTimingList.get(i).startTimeUs) {
return i;
}
}
return cuesWithTimingList.size();
}
}

View File

@ -56,7 +56,6 @@ public interface SubtitleDecoderFactory {
* <ul> * <ul>
* <li>Cea608 ({@link Cea608Decoder}) * <li>Cea608 ({@link Cea608Decoder})
* <li>Cea708 ({@link Cea708Decoder}) * <li>Cea708 ({@link Cea708Decoder})
* <li>Exoplayer Cues ({@link ExoplayerCuesDecoder})
* </ul> * </ul>
*/ */
SubtitleDecoderFactory DEFAULT = SubtitleDecoderFactory DEFAULT =
@ -70,8 +69,7 @@ public interface SubtitleDecoderFactory {
return delegate.supportsFormat(format) return delegate.supportsFormat(format)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_CEA608) || Objects.equals(mimeType, MimeTypes.APPLICATION_CEA608)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_MP4CEA608) || Objects.equals(mimeType, MimeTypes.APPLICATION_MP4CEA608)
|| Objects.equals(mimeType, MimeTypes.APPLICATION_CEA708) || Objects.equals(mimeType, MimeTypes.APPLICATION_CEA708);
|| Objects.equals(mimeType, MimeTypes.APPLICATION_MEDIA3_CUES);
} }
@Override @Override
@ -92,8 +90,6 @@ public interface SubtitleDecoderFactory {
Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS); Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS);
case MimeTypes.APPLICATION_CEA708: case MimeTypes.APPLICATION_CEA708:
return new Cea708Decoder(format.accessibilityChannel, format.initializationData); return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
case MimeTypes.APPLICATION_MEDIA3_CUES:
return new ExoplayerCuesDecoder();
default: default:
break; break;
} }

View File

@ -33,12 +33,15 @@ import androidx.media3.common.text.CueGroup;
import androidx.media3.common.util.Log; import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util; import androidx.media3.common.util.Util;
import androidx.media3.decoder.DecoderInputBuffer;
import androidx.media3.exoplayer.BaseRenderer; import androidx.media3.exoplayer.BaseRenderer;
import androidx.media3.exoplayer.FormatHolder; import androidx.media3.exoplayer.FormatHolder;
import androidx.media3.exoplayer.Renderer;
import androidx.media3.exoplayer.RendererCapabilities; import androidx.media3.exoplayer.RendererCapabilities;
import androidx.media3.exoplayer.source.MediaSource; import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.SampleStream.ReadDataResult; 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.SubtitleDecoder;
import androidx.media3.extractor.text.SubtitleDecoderException; import androidx.media3.extractor.text.SubtitleDecoderException;
import androidx.media3.extractor.text.SubtitleInputBuffer; import androidx.media3.extractor.text.SubtitleInputBuffer;
@ -48,16 +51,21 @@ import java.lang.annotation.Documented;
import java.lang.annotation.Retention; import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy; import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; 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.checker.nullness.qual.RequiresNonNull;
import org.checkerframework.dataflow.qual.SideEffectFree; import org.checkerframework.dataflow.qual.SideEffectFree;
/** /**
* A renderer for text. * A {@link Renderer} for text.
* *
* <p>{@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances * <p>This implementations decodes sample data to {@link Cue} instances. The actual rendering is
* obtained from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s * delegated to a {@link TextOutput}.
* 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 @UnstableApi
public final class TextRenderer extends BaseRenderer implements Callback { 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; private static final int MSG_UPDATE_OUTPUT = 0;
@Nullable private final Handler outputHandler; // Fields used when handling CuesWithTiming objects from application/x-media3-cues samples.
private final TextOutput output; private final CueDecoder cueDecoder;
private final SubtitleDecoderFactory decoderFactory; private final DecoderInputBuffer cueDecoderInputBuffer;
private final FormatHolder formatHolder; private @MonotonicNonNull CuesResolver cuesResolver;
private boolean inputStreamEnded; // Fields used when handling Subtitle objects from legacy samples.
private boolean outputStreamEnded; private final SubtitleDecoderFactory subtitleDecoderFactory;
private boolean waitingForKeyFrame; private boolean waitingForKeyFrame;
private @ReplacementState int decoderReplacementState; private @ReplacementState int decoderReplacementState;
@Nullable private Format streamFormat; @Nullable private SubtitleDecoder subtitleDecoder;
@Nullable private SubtitleDecoder decoder; @Nullable private SubtitleInputBuffer nextSubtitleInputBuffer;
@Nullable private SubtitleInputBuffer nextInputBuffer;
@Nullable private SubtitleOutputBuffer subtitle; @Nullable private SubtitleOutputBuffer subtitle;
@Nullable private SubtitleOutputBuffer nextSubtitle; @Nullable private SubtitleOutputBuffer nextSubtitle;
private int nextSubtitleEventIndex; 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 outputStreamOffsetUs;
private long lastRendererPositionUs; private long lastRendererPositionUs;
private long finalStreamEndPositionUs;
/** /**
* @param output The output. * @param output The output.
@ -130,15 +145,20 @@ public final class TextRenderer extends BaseRenderer implements Callback {
* looper associated with the application's main thread, which can be obtained using {@link * 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 * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called
* directly on the player's internal rendering thread. * 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( public TextRenderer(
TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) { TextOutput output,
@Nullable Looper outputLooper,
SubtitleDecoderFactory subtitleDecoderFactory) {
super(C.TRACK_TYPE_TEXT); super(C.TRACK_TYPE_TEXT);
this.output = checkNotNull(output); this.output = checkNotNull(output);
this.outputHandler = this.outputHandler =
outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); 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(); formatHolder = new FormatHolder();
finalStreamEndPositionUs = C.TIME_UNSET; finalStreamEndPositionUs = C.TIME_UNSET;
outputStreamOffsetUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET;
@ -152,7 +172,7 @@ public final class TextRenderer extends BaseRenderer implements Callback {
@Override @Override
public @Capabilities int supportsFormat(Format format) { public @Capabilities int supportsFormat(Format format) {
if (decoderFactory.supportsFormat(format)) { if (isCuesWithTiming(format) || subtitleDecoderFactory.supportsFormat(format)) {
return RendererCapabilities.create( return RendererCapabilities.create(
format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM); format.cryptoType == C.CRYPTO_TYPE_NONE ? C.FORMAT_HANDLED : C.FORMAT_UNSUPPORTED_DRM);
} else if (MimeTypes.isText(format.sampleMimeType)) { } else if (MimeTypes.isText(format.sampleMimeType)) {
@ -185,35 +205,46 @@ public final class TextRenderer extends BaseRenderer implements Callback {
MediaSource.MediaPeriodId mediaPeriodId) { MediaSource.MediaPeriodId mediaPeriodId) {
outputStreamOffsetUs = offsetUs; outputStreamOffsetUs = offsetUs;
streamFormat = formats[0]; streamFormat = formats[0];
if (decoder != null) { if (!isCuesWithTiming(streamFormat)) {
if (subtitleDecoder != null) {
decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
} else { } else {
initDecoder(); initSubtitleDecoder();
}
} else {
this.cuesResolver =
streamFormat.cueReplacementBehavior == Format.CUE_REPLACEMENT_BEHAVIOR_MERGE
? new MergingCuesResolver()
: new ReplacingCuesResolver();
} }
} }
@Override @Override
protected void onPositionReset(long positionUs, boolean joining) { protected void onPositionReset(long positionUs, boolean joining) {
lastRendererPositionUs = positionUs; lastRendererPositionUs = positionUs;
if (cuesResolver != null) {
cuesResolver.clear();
}
clearOutput(); clearOutput();
inputStreamEnded = false; inputStreamEnded = false;
outputStreamEnded = false; outputStreamEnded = false;
finalStreamEndPositionUs = C.TIME_UNSET; finalStreamEndPositionUs = C.TIME_UNSET;
if (streamFormat != null && !isCuesWithTiming(streamFormat)) {
if (decoderReplacementState != REPLACEMENT_STATE_NONE) { if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
replaceDecoder(); replaceSubtitleDecoder();
} else { } else {
releaseBuffers(); releaseSubtitleBuffers();
checkNotNull(decoder).flush(); checkNotNull(subtitleDecoder).flush();
}
} }
} }
@Override @Override
public void render(long positionUs, long elapsedRealtimeUs) { public void render(long positionUs, long elapsedRealtimeUs) {
lastRendererPositionUs = positionUs;
if (isCurrentStreamFinal() if (isCurrentStreamFinal()
&& finalStreamEndPositionUs != C.TIME_UNSET && finalStreamEndPositionUs != C.TIME_UNSET
&& positionUs >= finalStreamEndPositionUs) { && positionUs >= finalStreamEndPositionUs) {
releaseBuffers(); releaseSubtitleBuffers();
outputStreamEnded = true; outputStreamEnded = true;
} }
@ -221,10 +252,83 @@ public final class TextRenderer extends BaseRenderer implements Callback {
return; 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<Cue> 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) { if (nextSubtitle == null) {
checkNotNull(decoder).setPositionUs(positionUs); checkNotNull(subtitleDecoder).setPositionUs(positionUs);
try { try {
nextSubtitle = checkNotNull(decoder).dequeueOutputBuffer(); nextSubtitle = checkNotNull(subtitleDecoder).dequeueOutputBuffer();
} catch (SubtitleDecoderException e) { } catch (SubtitleDecoderException e) {
handleDecoderError(e); handleDecoderError(e);
return; return;
@ -251,9 +355,9 @@ public final class TextRenderer extends BaseRenderer implements Callback {
if (nextSubtitle.isEndOfStream()) { if (nextSubtitle.isEndOfStream()) {
if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) { if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) {
if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) {
replaceDecoder(); replaceSubtitleDecoder();
} else { } else {
releaseBuffers(); releaseSubtitleBuffers();
outputStreamEnded = true; outputStreamEnded = true;
} }
} }
@ -284,18 +388,18 @@ public final class TextRenderer extends BaseRenderer implements Callback {
try { try {
while (!inputStreamEnded) { while (!inputStreamEnded) {
@Nullable SubtitleInputBuffer nextInputBuffer = this.nextInputBuffer; @Nullable SubtitleInputBuffer nextInputBuffer = this.nextSubtitleInputBuffer;
if (nextInputBuffer == null) { if (nextInputBuffer == null) {
nextInputBuffer = checkNotNull(decoder).dequeueInputBuffer(); nextInputBuffer = checkNotNull(subtitleDecoder).dequeueInputBuffer();
if (nextInputBuffer == null) { if (nextInputBuffer == null) {
return; return;
} }
this.nextInputBuffer = nextInputBuffer; this.nextSubtitleInputBuffer = nextInputBuffer;
} }
if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) { if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) {
nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
checkNotNull(decoder).queueInputBuffer(nextInputBuffer); checkNotNull(subtitleDecoder).queueInputBuffer(nextInputBuffer);
this.nextInputBuffer = null; this.nextSubtitleInputBuffer = null;
decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM; decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM;
return; return;
} }
@ -319,8 +423,8 @@ public final class TextRenderer extends BaseRenderer implements Callback {
if (nextInputBuffer.timeUs < getLastResetPositionUs()) { if (nextInputBuffer.timeUs < getLastResetPositionUs()) {
nextInputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); nextInputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
} }
checkNotNull(decoder).queueInputBuffer(nextInputBuffer); checkNotNull(subtitleDecoder).queueInputBuffer(nextInputBuffer);
this.nextInputBuffer = null; this.nextSubtitleInputBuffer = null;
} }
} else if (result == C.RESULT_NOTHING_READ) { } else if (result == C.RESULT_NOTHING_READ) {
return; return;
@ -338,7 +442,9 @@ public final class TextRenderer extends BaseRenderer implements Callback {
clearOutput(); clearOutput();
outputStreamOffsetUs = C.TIME_UNSET; outputStreamOffsetUs = C.TIME_UNSET;
lastRendererPositionUs = C.TIME_UNSET; lastRendererPositionUs = C.TIME_UNSET;
releaseDecoder(); if (subtitleDecoder != null) {
releaseSubtitleDecoder();
}
} }
@Override @Override
@ -353,8 +459,8 @@ public final class TextRenderer extends BaseRenderer implements Callback {
return true; return true;
} }
private void releaseBuffers() { private void releaseSubtitleBuffers() {
nextInputBuffer = null; nextSubtitleInputBuffer = null;
nextSubtitleEventIndex = C.INDEX_UNSET; nextSubtitleEventIndex = C.INDEX_UNSET;
if (subtitle != null) { if (subtitle != null) {
subtitle.release(); subtitle.release();
@ -366,21 +472,21 @@ public final class TextRenderer extends BaseRenderer implements Callback {
} }
} }
private void releaseDecoder() { private void releaseSubtitleDecoder() {
releaseBuffers(); releaseSubtitleBuffers();
checkNotNull(decoder).release(); checkNotNull(subtitleDecoder).release();
decoder = null; subtitleDecoder = null;
decoderReplacementState = REPLACEMENT_STATE_NONE; decoderReplacementState = REPLACEMENT_STATE_NONE;
} }
private void initDecoder() { private void initSubtitleDecoder() {
waitingForKeyFrame = true; waitingForKeyFrame = true;
decoder = decoderFactory.createDecoder(checkNotNull(streamFormat)); subtitleDecoder = subtitleDecoderFactory.createDecoder(checkNotNull(streamFormat));
} }
private void replaceDecoder() { private void replaceSubtitleDecoder() {
releaseDecoder(); releaseSubtitleDecoder();
initDecoder(); initSubtitleDecoder();
} }
private long getNextEventTime() { private long getNextEventTime() {
@ -423,7 +529,7 @@ public final class TextRenderer extends BaseRenderer implements Callback {
} }
/** /**
* 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. * continue.
* *
* <p>Logs {@code e} and resets state to allow decoding the next sample. * <p>Logs {@code e} and resets state to allow decoding the next sample.
@ -431,7 +537,7 @@ public final class TextRenderer extends BaseRenderer implements Callback {
private void handleDecoderError(SubtitleDecoderException e) { private void handleDecoderError(SubtitleDecoderException e) {
Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e);
clearOutput(); clearOutput();
replaceDecoder(); replaceSubtitleDecoder();
} }
@RequiresNonNull("subtitle") @RequiresNonNull("subtitle")
@ -454,4 +560,9 @@ public final class TextRenderer extends BaseRenderer implements Callback {
return positionUs - outputStreamOffsetUs; 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);
}
} }

View File

@ -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);
}
}

View File

@ -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<SubtitleInputBuffer> 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);
}
}

View File

@ -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<Cue> FIRST_CUES =
ImmutableList.of(new Cue.Builder().setText("first cue").build());
public static final ImmutableList<Cue> SECOND_CUES =
ImmutableList.of(
new Cue.Builder().setText("second group: cue1").build(),
new Cue.Builder().setText("second group: cue2").build());
public static final ImmutableList<Cue> 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();
}
}

View File

@ -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<Cue> FIRST_CUES =
ImmutableList.of(new Cue.Builder().setText("first cue").build());
public static final ImmutableList<Cue> SECOND_CUES =
ImmutableList.of(
new Cue.Builder().setText("second group: cue1").build(),
new Cue.Builder().setText("second group: cue2").build());
public static final ImmutableList<Cue> 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();
}
}

View File

@ -43,8 +43,22 @@ public final class CueDecoder {
* @return Decoded {@link CuesWithTiming} instance. * @return Decoded {@link CuesWithTiming} instance.
*/ */
public CuesWithTiming decode(long startTimeUs, byte[] bytes) { 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 parcel = Parcel.obtain();
parcel.unmarshall(bytes, 0, bytes.length); parcel.unmarshall(bytes, offset, length);
parcel.setDataPosition(0); parcel.setDataPosition(0);
Bundle bundle = parcel.readBundle(Bundle.class.getClassLoader()); Bundle bundle = parcel.readBundle(Bundle.class.getClassLoader());
parcel.recycle(); parcel.recycle();

View File

@ -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 * 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 cues Cues to be encoded.
* @param durationUs Duration to be encoded, in microseconds. * @param durationUs Duration to be encoded, in microseconds.

View File

@ -533,11 +533,27 @@ TextOutput:
Subtitle[2]: Subtitle[2]:
presentationTimeUs = 150000 presentationTimeUs = 150000
Cue[0]: Cue[0]:
text = First subtitle - end overlaps second
Cue[1]:
text = Third subtitle - fully encompasses second text = Third subtitle - fully encompasses second
Subtitle[3]: Subtitle[3]:
presentationTimeUs = 200000 presentationTimeUs = 200000
Cue[0]: Cue[0]:
text = First subtitle - end overlaps second
Cue[1]:
text = Third subtitle - fully encompasses second
Cue[2]:
text = Second subtitle - beginning overlaps first text = Second subtitle - beginning overlaps first
Subtitle[4]: Subtitle[4]:
presentationTimeUs = 330000
Cue[0]:
text = Third subtitle - fully encompasses second
Cue[1]:
text = Second subtitle - beginning overlaps first
Subtitle[5]:
presentationTimeUs = 450000 presentationTimeUs = 450000
Cue[0]:
text = Third subtitle - fully encompasses second
Subtitle[6]:
presentationTimeUs = 550000
Cues = [] Cues = []

View File

@ -534,13 +534,35 @@ TextOutput:
Subtitle[2]: Subtitle[2]:
presentationTimeUs = 150000 presentationTimeUs = 150000
Cue[0]: Cue[0]:
text = First subtitle - end overlaps second
lineType = 0
Cue[1]:
text = Third subtitle - fully encompasses second text = Third subtitle - fully encompasses second
lineType = 0 lineType = 0
Subtitle[3]: Subtitle[3]:
presentationTimeUs = 200000 presentationTimeUs = 200000
Cue[0]: 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 text = Second subtitle - beginning overlaps first
lineType = 0 lineType = 0
Subtitle[4]: 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 presentationTimeUs = 450000
Cue[0]:
text = Third subtitle - fully encompasses second
lineType = 0
Subtitle[6]:
presentationTimeUs = 550000
Cues = [] Cues = []