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:
parent
49b1e0bbc2
commit
002ee0555d
@ -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.
|
||||||
|
@ -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();
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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)) {
|
||||||
decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
|
if (subtitleDecoder != null) {
|
||||||
|
decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
|
||||||
|
} else {
|
||||||
|
initSubtitleDecoder();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
initDecoder();
|
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 (decoderReplacementState != REPLACEMENT_STATE_NONE) {
|
if (streamFormat != null && !isCuesWithTiming(streamFormat)) {
|
||||||
replaceDecoder();
|
if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
|
||||||
} else {
|
replaceSubtitleDecoder();
|
||||||
releaseBuffers();
|
} else {
|
||||||
checkNotNull(decoder).flush();
|
releaseSubtitleBuffers();
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
@ -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.
|
||||||
|
@ -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 = []
|
||||||
|
@ -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 = []
|
||||||
|
Loading…
x
Reference in New Issue
Block a user