diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java index 0e1a6dac72..2cf7703e6c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/text/SubtitleDecoderFactory.java @@ -25,7 +25,7 @@ import androidx.media3.extractor.text.cea.Cea708Decoder; import androidx.media3.extractor.text.dvb.DvbDecoder; import androidx.media3.extractor.text.pgs.PgsDecoder; import androidx.media3.extractor.text.ssa.SsaParser; -import androidx.media3.extractor.text.subrip.SubripDecoder; +import androidx.media3.extractor.text.subrip.SubripParser; import androidx.media3.extractor.text.ttml.TtmlDecoder; import androidx.media3.extractor.text.tx3g.Tx3gDecoder; import androidx.media3.extractor.text.webvtt.Mp4WebvttDecoder; @@ -62,7 +62,7 @@ public interface SubtitleDecoderFactory { *
  • WebVTT ({@link WebvttDecoder}) *
  • WebVTT (MP4) ({@link Mp4WebvttDecoder}) *
  • TTML ({@link TtmlDecoder}) - *
  • SubRip ({@link SubripDecoder}) + *
  • SubRip ({@link SubripParser}) *
  • SSA/ASS ({@link SsaParser}) *
  • TX3G ({@link Tx3gDecoder}) *
  • Cea608 ({@link Cea608Decoder}) @@ -108,7 +108,8 @@ public interface SubtitleDecoderFactory { case MimeTypes.APPLICATION_TTML: return new TtmlDecoder(); case MimeTypes.APPLICATION_SUBRIP: - return new SubripDecoder(); + return new DelegatingSubtitleDecoder( + "DelegatingSubtitleDecoderWithSubripParser", new SubripParser()); case MimeTypes.APPLICATION_TX3G: return new Tx3gDecoder(format.initializationData); case MimeTypes.APPLICATION_CEA608: diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/DelegatingSubtitleDecoderWithSubripParserTest.java similarity index 84% rename from libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java rename to libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/DelegatingSubtitleDecoderWithSubripParserTest.java index 642e20e259..9e8a650955 100644 --- a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripDecoderTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/text/DelegatingSubtitleDecoderWithSubripParserTest.java @@ -1,11 +1,11 @@ /* - * Copyright (C) 2016 The Android Open Source Project + * 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 * - * http://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -13,12 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package androidx.media3.extractor.text.subrip; +package androidx.media3.exoplayer.text; import static com.google.common.truth.Truth.assertThat; import androidx.media3.common.text.Cue; import androidx.media3.extractor.text.Subtitle; +import androidx.media3.extractor.text.subrip.SubripParser; import androidx.media3.test.utils.TestUtil; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -26,9 +27,9 @@ import java.io.IOException; import org.junit.Test; import org.junit.runner.RunWith; -/** Unit test for {@link SubripDecoder}. */ +/** Unit test for a DelegatingSubtitleDecoder backed by {@link SubripParser}. */ @RunWith(AndroidJUnit4.class) -public final class SubripDecoderTest { +public class DelegatingSubtitleDecoderWithSubripParserTest { private static final String EMPTY_FILE = "media/subrip/empty"; private static final String TYPICAL_FILE = "media/subrip/typical"; @@ -48,7 +49,8 @@ public final class SubripDecoderTest { @Test public void decodeEmpty() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE); Subtitle subtitle = decoder.decode(bytes, bytes.length, false); @@ -59,7 +61,8 @@ public final class SubripDecoderTest { @Test public void decodeTypical() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); Subtitle subtitle = decoder.decode(bytes, bytes.length, false); @@ -72,7 +75,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalWithByteOrderMark() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK); @@ -87,7 +91,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalExtraBlankLine() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE); @@ -103,7 +108,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalMissingTimecode() throws IOException { // Parsing should succeed, parsing the first and third cues only. - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE); @@ -118,7 +124,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalMissingSequence() throws IOException { // Parsing should succeed, parsing the first and third cues only. - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE); @@ -133,7 +140,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalNegativeTimestamps() throws IOException { // Parsing should succeed, parsing the third cue only. - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS); @@ -147,7 +155,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalUnexpectedEnd() throws IOException { // Parsing should succeed, parsing the first and second cues only. - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END); @@ -160,7 +169,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalUtf16LittleEndian() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE); @@ -174,7 +184,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalUtf16BigEndian() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE); @@ -188,7 +199,8 @@ public final class SubripDecoderTest { @Test public void decodeCueWithTag() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS); @@ -217,7 +229,8 @@ public final class SubripDecoderTest { @Test public void decodeTypicalNoHoursAndMillis() throws IOException { - SubripDecoder decoder = new SubripDecoder(); + DelegatingSubtitleDecoder decoder = + new DelegatingSubtitleDecoder("SubripDecoder", new SubripParser()); byte[] bytes = TestUtil.getByteArray( ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS); @@ -263,9 +276,9 @@ public final class SubripDecoderTest { Cue cue = subtitle.getCues(eventTimeUs).get(0); assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); assertThat(cue.lineAnchor).isEqualTo(lineAnchor); - assertThat(cue.line).isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(lineAnchor)); assertThat(cue.positionAnchor).isEqualTo(positionAnchor); + assertThat(cue.line).isEqualTo(SubripParser.getFractionalPositionForAnchorType(lineAnchor)); assertThat(cue.position) - .isEqualTo(SubripDecoder.getFractionalPositionForAnchorType(positionAnchor)); + .isEqualTo(SubripParser.getFractionalPositionForAnchorType(positionAnchor)); } } diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java similarity index 79% rename from libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java rename to libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java index 6147ff92ad..ca99741fc4 100644 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripDecoder.java +++ b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripParser.java @@ -15,34 +15,40 @@ */ package androidx.media3.extractor.text.subrip; +import static androidx.annotation.VisibleForTesting.PRIVATE; + import android.text.Html; import android.text.Spanned; import android.text.TextUtils; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.media3.common.C; import androidx.media3.common.text.Cue; import androidx.media3.common.util.Assertions; import androidx.media3.common.util.Log; import androidx.media3.common.util.LongArray; import androidx.media3.common.util.ParsableByteArray; import androidx.media3.common.util.UnstableApi; -import androidx.media3.extractor.text.SimpleSubtitleDecoder; -import androidx.media3.extractor.text.Subtitle; +import androidx.media3.common.util.Util; +import androidx.media3.extractor.text.CuesWithTiming; +import androidx.media3.extractor.text.SubtitleParser; import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableList; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** A {@link SimpleSubtitleDecoder} for SubRip. */ +/** A {@link SubtitleParser} for SubRip. */ @UnstableApi -public final class SubripDecoder extends SimpleSubtitleDecoder { +public final class SubripParser implements SubtitleParser { // Fractional positions for use when alignment tags are present. private static final float START_FRACTION = 0.08f; private static final float END_FRACTION = 1 - START_FRACTION; private static final float MID_FRACTION = 0.5f; - private static final String TAG = "SubripDecoder"; + private static final String TAG = "SubripParser"; // Some SRT files don't include hours or milliseconds in the timecode, so we use optional groups. private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:,(\\d+))?"; @@ -66,18 +72,25 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { private final StringBuilder textBuilder; private final ArrayList tags; + private byte[] dataScratch = Util.EMPTY_BYTE_ARRAY; - public SubripDecoder() { - super("SubripDecoder"); + public SubripParser() { textBuilder = new StringBuilder(); tags = new ArrayList<>(); } + @Nullable @Override - protected Subtitle decode(byte[] data, int length, boolean reset) { + public ImmutableList parse(byte[] data, int offset, int length) { ArrayList cues = new ArrayList<>(); - LongArray cueTimesUs = new LongArray(); - ParsableByteArray subripData = new ParsableByteArray(data, length); + LongArray startTimesUs = new LongArray(); + + if (dataScratch.length < length) { + dataScratch = new byte[length]; + } + System.arraycopy( + /* src= */ data, /* scrPos= */ offset, /* dest= */ dataScratch, /* destPos= */ 0, length); + ParsableByteArray subripData = new ParsableByteArray(dataScratch, length); Charset charset = detectUtfCharset(subripData); @Nullable String currentLine; @@ -104,8 +117,8 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine); if (matcher.matches()) { - cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1)); - cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6)); + startTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1)); + startTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6)); } else { Log.w(TAG, "Skipping invalid timing: " + currentLine); continue; @@ -138,11 +151,22 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { cues.add(Cue.EMPTY); } - Cue[] cuesArray = cues.toArray(new Cue[0]); - long[] cueTimesUsArray = cueTimesUs.toArray(); - return new SubripSubtitle(cuesArray, cueTimesUsArray); + ImmutableList.Builder cuesWithStartTimeAndDuration = ImmutableList.builder(); + for (int i = 0; i < cues.size(); i++) { + cuesWithStartTimeAndDuration.add( + new CuesWithTiming( + /* cues= */ ImmutableList.of(cues.get(i)), + /* startTimeUs= */ startTimesUs.get(i), + /* durationUs= */ i == cues.size() - 1 + ? C.TIME_UNSET + : startTimesUs.get(i + 1) - startTimesUs.get(i))); + } + return cuesWithStartTimeAndDuration.build(); } + @Override + public void reset() {} + /** * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if * no BOM is found. @@ -248,14 +272,17 @@ public final class SubripDecoder extends SimpleSubtitleDecoder { return timestampMs * 1000; } - /* package */ static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) { + // TODO(b/289983417): Make package-private again, once it is no longer needed in + // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) + @VisibleForTesting(otherwise = PRIVATE) + public static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) { switch (anchorType) { case Cue.ANCHOR_TYPE_START: - return SubripDecoder.START_FRACTION; + return START_FRACTION; case Cue.ANCHOR_TYPE_MIDDLE: - return SubripDecoder.MID_FRACTION; + return MID_FRACTION; case Cue.ANCHOR_TYPE_END: - return SubripDecoder.END_FRACTION; + return END_FRACTION; case Cue.TYPE_UNSET: default: // Should never happen. diff --git a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripSubtitle.java b/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripSubtitle.java deleted file mode 100644 index 68ee42ff50..0000000000 --- a/libraries/extractor/src/main/java/androidx/media3/extractor/text/subrip/SubripSubtitle.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (C) 2016 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.extractor.text.subrip; - -import androidx.media3.common.C; -import androidx.media3.common.text.Cue; -import androidx.media3.common.util.Assertions; -import androidx.media3.common.util.Util; -import androidx.media3.extractor.text.Subtitle; -import java.util.Collections; -import java.util.List; - -/** A representation of a SubRip subtitle. */ -/* package */ final class SubripSubtitle implements Subtitle { - - private final Cue[] cues; - private final long[] cueTimesUs; - - /** - * @param cues The cues in the subtitle. - * @param cueTimesUs The cue times, in microseconds. - */ - public SubripSubtitle(Cue[] cues, long[] cueTimesUs) { - this.cues = cues; - this.cueTimesUs = cueTimesUs; - } - - @Override - public int getNextEventTimeIndex(long timeUs) { - int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); - return index < cueTimesUs.length ? index : C.INDEX_UNSET; - } - - @Override - public int getEventTimeCount() { - return cueTimesUs.length; - } - - @Override - public long getEventTime(int index) { - Assertions.checkArgument(index >= 0); - Assertions.checkArgument(index < cueTimesUs.length); - return cueTimesUs[index]; - } - - @Override - public List getCues(long timeUs) { - int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); - if (index == -1 || cues[index] == Cue.EMPTY) { - // timeUs is earlier than the start of the first cue, or we have an empty cue. - return Collections.emptyList(); - } else { - return Collections.singletonList(cues[index]); - } - } -} diff --git a/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java new file mode 100644 index 0000000000..f887af9fa9 --- /dev/null +++ b/libraries/extractor/src/test/java/androidx/media3/extractor/text/subrip/SubripParserTest.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2016 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.extractor.text.subrip; + +import static com.google.common.truth.Truth.assertThat; + +import androidx.media3.common.text.Cue; +import androidx.media3.extractor.text.CuesWithTiming; +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 org.junit.Test; +import org.junit.runner.RunWith; + +/** Unit test for {@link SubripParser}. */ +@RunWith(AndroidJUnit4.class) +public final class SubripParserTest { + + private static final String EMPTY_FILE = "media/subrip/empty"; + private static final String TYPICAL_FILE = "media/subrip/typical"; + private static final String TYPICAL_WITH_BYTE_ORDER_MARK = + "media/subrip/typical_with_byte_order_mark"; + private static final String TYPICAL_EXTRA_BLANK_LINE = "media/subrip/typical_extra_blank_line"; + private static final String TYPICAL_MISSING_TIMECODE = "media/subrip/typical_missing_timecode"; + private static final String TYPICAL_MISSING_SEQUENCE = "media/subrip/typical_missing_sequence"; + private static final String TYPICAL_NEGATIVE_TIMESTAMPS = + "media/subrip/typical_negative_timestamps"; + private static final String TYPICAL_UNEXPECTED_END = "media/subrip/typical_unexpected_end"; + private static final String TYPICAL_UTF16BE = "media/subrip/typical_utf16be"; + private static final String TYPICAL_UTF16LE = "media/subrip/typical_utf16le"; + private static final String TYPICAL_WITH_TAGS = "media/subrip/typical_with_tags"; + private static final String TYPICAL_NO_HOURS_AND_MILLIS = + "media/subrip/typical_no_hours_and_millis"; + + @Test + public void parseEmpty() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), EMPTY_FILE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).isEmpty(); + } + + @Test + public void parseTypical() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + assertTypicalCue3(allCues.get(4)); + } + + @Test + public void parseTypicalAtOffsetAndRestrictedLength() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_FILE); + + ImmutableList allCues = parser.parse(bytes, 10, bytes.length - 15); + + assertThat(allCues).hasSize(4); + // Because of the offset, we skip the first line of dialogue + assertTypicalCue2(allCues.get(0)); + // Because of the length restriction, we only partially parse the third line of dialogue + Cue thirdCue = allCues.get(2).cues.get(0); + assertThat(thirdCue.text.toString()).isEqualTo("This is the third subti"); + } + + @Test + public void parseTypicalWithByteOrderMark() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_WITH_BYTE_ORDER_MARK); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + assertTypicalCue3(allCues.get(4)); + } + + @Test + public void parseTypicalExtraBlankLine() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_EXTRA_BLANK_LINE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + assertTypicalCue3(allCues.get(4)); + } + + @Test + public void parseTypicalMissingTimecode() throws IOException { + // Parsing should succeed, parsing the first and third cues only. + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_TIMECODE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(4); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue3(allCues.get(2)); + } + + @Test + public void parseTypicalMissingSequence() throws IOException { + // Parsing should succeed, parsing the first and third cues only. + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_MISSING_SEQUENCE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(4); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue3(allCues.get(2)); + } + + @Test + public void parseTypicalNegativeTimestamps() throws IOException { + // Parsing should succeed, parsing the third cue only. + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_NEGATIVE_TIMESTAMPS); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(2); + assertTypicalCue3(allCues.get(0)); + } + + @Test + public void parseTypicalUnexpectedEnd() throws IOException { + // Parsing should succeed, parsing the first and second cues only. + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UNEXPECTED_END); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(4); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + } + + @Test + public void parseTypicalUtf16LittleEndian() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16LE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + assertTypicalCue3(allCues.get(4)); + } + + @Test + public void parseTypicalUtf16BigEndian() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_UTF16BE); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertTypicalCue2(allCues.get(2)); + assertTypicalCue3(allCues.get(4)); + } + + @Test + public void parseCueWithTag() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray(ApplicationProvider.getApplicationContext(), TYPICAL_WITH_TAGS); + + List allCues = parser.parse(bytes); + + assertThat(allCues).isNotNull(); + assertThat(allCues.get(0).cues.get(0).text.toString()).isEqualTo("This is the first subtitle."); + assertThat(allCues.get(2).cues.get(0).text.toString()) + .isEqualTo("This is the second subtitle.\nSecond subtitle with second line."); + assertThat(allCues.get(4).cues.get(0).text.toString()).isEqualTo("This is the third subtitle."); + assertThat(allCues.get(6).cues.get(0).text.toString()) + .isEqualTo("This { \\an2} is not a valid tag due to the space after the opening bracket."); + assertThat(allCues.get(8).cues.get(0).text.toString()) + .isEqualTo("This is the fifth subtitle with multiple valid tags."); + assertAlignmentCue(allCues.get(10), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_START); // {/an1} + assertAlignmentCue(allCues.get(12), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_MIDDLE); // {/an2} + assertAlignmentCue(allCues.get(14), Cue.ANCHOR_TYPE_END, Cue.ANCHOR_TYPE_END); // {/an3} + assertAlignmentCue(allCues.get(16), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_START); // {/an4} + assertAlignmentCue(allCues.get(18), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_MIDDLE); // {/an5} + assertAlignmentCue(allCues.get(20), Cue.ANCHOR_TYPE_MIDDLE, Cue.ANCHOR_TYPE_END); // {/an6} + assertAlignmentCue(allCues.get(22), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_START); // {/an7} + assertAlignmentCue(allCues.get(24), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_MIDDLE); // {/an8} + assertAlignmentCue(allCues.get(26), Cue.ANCHOR_TYPE_START, Cue.ANCHOR_TYPE_END); // {/an9} + } + + @Test + public void parseTypicalNoHoursAndMillis() throws IOException { + SubripParser parser = new SubripParser(); + byte[] bytes = + TestUtil.getByteArray( + ApplicationProvider.getApplicationContext(), TYPICAL_NO_HOURS_AND_MILLIS); + + List allCues = parser.parse(bytes); + + assertThat(allCues).hasSize(6); + assertTypicalCue1(allCues.get(0)); + assertThat(allCues.get(2).startTimeUs).isEqualTo(2_000_000); + assertThat(allCues.get(3).startTimeUs).isEqualTo(3_000_000); + assertTypicalCue3(allCues.get(4)); + } + + private static void assertTypicalCue1(CuesWithTiming cuesWithTiming) { + assertThat(cuesWithTiming.startTimeUs).isEqualTo(0); + assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the first subtitle."); + assertThat(cuesWithTiming.durationUs).isEqualTo(1234000); + } + + private static void assertTypicalCue2(CuesWithTiming cuesWithTiming) { + assertThat(cuesWithTiming.startTimeUs).isEqualTo(2345000); + assertThat(cuesWithTiming.cues.get(0).text.toString()) + .isEqualTo("This is the second subtitle.\nSecond subtitle with second line."); + assertThat(cuesWithTiming.durationUs).isEqualTo(3456000 - 2345000); + } + + private static void assertTypicalCue3(CuesWithTiming cuesWithTiming) { + long expectedStartTimeUs = (((2L * 60L * 60L) + 4L) * 1000L + 567L) * 1000L; + assertThat(cuesWithTiming.startTimeUs).isEqualTo(expectedStartTimeUs); + assertThat(cuesWithTiming.cues.get(0).text.toString()).isEqualTo("This is the third subtitle."); + long expectedEndTimeUs = (((2L * 60L * 60L) + 8L) * 1000L + 901L) * 1000L; + assertThat(cuesWithTiming.durationUs).isEqualTo(expectedEndTimeUs - expectedStartTimeUs); + } + + private static void assertAlignmentCue( + CuesWithTiming cuesWithTiming, + @Cue.AnchorType int lineAnchor, + @Cue.AnchorType int positionAnchor) { + Cue cue = cuesWithTiming.cues.get(0); + assertThat(cue.lineType).isEqualTo(Cue.LINE_TYPE_FRACTION); + assertThat(cue.lineAnchor).isEqualTo(lineAnchor); + assertThat(cue.line).isEqualTo(SubripParser.getFractionalPositionForAnchorType(lineAnchor)); + assertThat(cue.positionAnchor).isEqualTo(positionAnchor); + assertThat(cue.position) + .isEqualTo(SubripParser.getFractionalPositionForAnchorType(positionAnchor)); + } +}