diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/Subtitle.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/Subtitle.java index 9ad0626e40..b82caf7b11 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/Subtitle.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/Subtitle.java @@ -18,6 +18,7 @@ package androidx.media3.extractor.text; import androidx.media3.common.C; import androidx.media3.common.text.Cue; import androidx.media3.common.util.UnstableApi; +import com.google.common.collect.ImmutableList; import java.util.List; /** A subtitle consisting of timed {@link Cue}s. */ @@ -56,4 +57,22 @@ public interface Subtitle { * @return A list of cues that should be displayed, possibly empty. */ List getCues(long timeUs); + + /** Converts the current instance to a list of {@link CuesWithTiming} representing it. */ + default ImmutableList toCuesWithTimingList() { + ImmutableList.Builder allCues = ImmutableList.builder(); + for (int i = 0; i < getEventTimeCount(); i++) { + long startTimeUs = getEventTime(i); + List cuesForThisStartTime = getCues(startTimeUs); + if (cuesForThisStartTime.isEmpty() && i != 0) { + // An empty cue list has already been implicitly encoded in the duration of the previous + // sample (unless there was no previous sample). + continue; + } + long durationUs = + i < getEventTimeCount() - 1 ? getEventTime(i + 1) - getEventTime(i) : C.TIME_UNSET; + allCues.add(new CuesWithTiming(cuesForThisStartTime, startTimeUs, durationUs)); + } + return allCues.build(); + } } diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/ttml/TtmlDecoderTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/ttml/TtmlDecoderTest.java index c1b9a69fec..8451776f92 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/ttml/TtmlDecoderTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/ttml/TtmlDecoderTest.java @@ -26,11 +26,13 @@ import androidx.media3.common.text.TextAnnotation; import androidx.media3.common.text.TextEmphasisSpan; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.ColorParser; +import androidx.media3.extractor.text.CuesWithTiming; import androidx.media3.extractor.text.Subtitle; import androidx.media3.extractor.text.SubtitleDecoderException; import androidx.media3.test.utils.TestUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ImmutableList; import java.io.IOException; import java.util.List; import java.util.Map; @@ -890,6 +892,39 @@ public final class TtmlDecoderTest { assertThat(eighthCue.shearDegrees).isWithin(0.01f).of(90f); } + @Test + public void toCuesWithTimingConversion() throws IOException, SubtitleDecoderException { + TtmlSubtitle subtitle = getSubtitle(INLINE_ATTRIBUTES_TTML_FILE); + ImmutableList cuesWithTimingsList = subtitle.toCuesWithTimingList(); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(4); + assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(3))).isEmpty(); + // cuesWithTimingsList has 2 fewer the events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(2); + + subtitle = getSubtitle(INHERIT_STYLE_TTML_FILE); + cuesWithTimingsList = subtitle.toCuesWithTimingList(); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(2); + assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty(); + // cuesWithTimingsList has 1 fewer events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(1); + + subtitle = getSubtitle(INHERIT_MULTIPLE_STYLES_TTML_FILE); + cuesWithTimingsList = subtitle.toCuesWithTimingList(); + + assertThat(subtitle.getEventTimeCount()).isEqualTo(12); + assertThat(subtitle.getCues(subtitle.getEventTime(1))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(3))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(5))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(7))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(9))).isEmpty(); + assertThat(subtitle.getCues(subtitle.getEventTime(11))).isEmpty(); + // cuesWithTimingsList has 6 fewer events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(6); + } + private static Spanned getOnlyCueTextAtTimeUs(Subtitle subtitle, long timeUs) { Cue cue = getOnlyCueAtTimeUs(subtitle, timeUs); assertThat(cue.text).isInstanceOf(Spanned.class); diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/webvtt/WebvttSubtitleTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/webvtt/WebvttSubtitleTest.java index 4f22e3adfb..adbd226606 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/webvtt/WebvttSubtitleTest.java +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/webvtt/WebvttSubtitleTest.java @@ -20,7 +20,9 @@ import static com.google.common.truth.Truth.assertThat; import static java.lang.Long.MAX_VALUE; 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 java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -297,6 +299,31 @@ public class WebvttSubtitleTest { assertThat(nestedSubtitle.getCues(Long.MAX_VALUE)).isEmpty(); } + @Test + public void toCuesWithTimingConversion() { + ImmutableList cuesWithTimingsList = simpleSubtitle.toCuesWithTimingList(); + + assertThat(simpleSubtitle.getEventTimeCount()).isEqualTo(4); + assertThat(simpleSubtitle.getCues(simpleSubtitle.getEventTime(1))).isEmpty(); + assertThat(simpleSubtitle.getCues(simpleSubtitle.getEventTime(3))).isEmpty(); + // cuesWithTimingsList has half the events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(2); + + cuesWithTimingsList = overlappingSubtitle.toCuesWithTimingList(); + + assertThat(overlappingSubtitle.getEventTimeCount()).isEqualTo(4); + assertThat(overlappingSubtitle.getCues(overlappingSubtitle.getEventTime(3))).isEmpty(); + // cuesWithTimingsList has one fewer events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(3); + + cuesWithTimingsList = nestedSubtitle.toCuesWithTimingList(); + + assertThat(nestedSubtitle.getEventTimeCount()).isEqualTo(4); + assertThat(nestedSubtitle.getCues(nestedSubtitle.getEventTime(3))).isEmpty(); + // cuesWithTimingsList has one fewer events because it skips the empty cues + assertThat(cuesWithTimingsList).hasSize(3); + } + private static List getCueTexts(List cues) { List cueTexts = new ArrayList<>(); for (int i = 0; i < cues.size(); i++) {