diff --git a/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripCue.java b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripCue.java new file mode 100644 index 0000000000..c60aeeb679 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripCue.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2014 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 com.google.android.exoplayer.text.subrip; + +import com.google.android.exoplayer.text.Cue; + +/** + * A representation of a SubRip cue. + */ +/* package */ final class SubripCue extends Cue { + + public final long startTime; + public final long endTime; + + public SubripCue(CharSequence text) { + this(Cue.UNSET_VALUE, Cue.UNSET_VALUE, Cue.UNSET_VALUE,text); + } + + public SubripCue(long startTime, long endTime, int position, CharSequence text) { + super(text, Cue.UNSET_VALUE, position, null, Cue.UNSET_VALUE); + this.startTime = startTime; + this.endTime = endTime; + } + + /** + * Returns whether or not this cue should be placed in the default position and rolled-up with + * the other "normal" cues. + * + * @return True if this cue should be placed in the default position; false otherwise. + */ + public boolean isNormalCue() { + return (line == UNSET_VALUE && position == UNSET_VALUE); + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripParser.java b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripParser.java new file mode 100644 index 0000000000..66a4dbff86 --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripParser.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2014 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 com.google.android.exoplayer.text.subrip; + +import android.text.Html; + +import com.google.android.exoplayer.C; +import com.google.android.exoplayer.ParserException; +import com.google.android.exoplayer.text.Cue; +import com.google.android.exoplayer.text.SubtitleParser; +import com.google.android.exoplayer.util.MimeTypes; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A simple SRT parser. + *

+ * + * @see Wikipedia on SRT + */ +public final class SubripParser implements SubtitleParser { + + private static final String TAG = "SubRipParser"; + + private static final String SUBRIP_POSITION_STRING = "^(\\d)$"; + private static final Pattern SUBRIP_POSITION = Pattern.compile(SUBRIP_POSITION_STRING); + + private static final String SUBRIP_CUE_IDENTIFIER_STRING = "^(.*)\\s-->\\s(.*)$"; + private static final Pattern SUBRIP_CUE_IDENTIFIER = + Pattern.compile(SUBRIP_CUE_IDENTIFIER_STRING); + + private static final String SUBRIP_TIMESTAMP_STRING = "(\\d+:)?[0-5]\\d:[0-5]\\d:[0-5]\\d,\\d{3}"; + // private static final Pattern SUBRIP_TIMESTAMP = Pattern.compile(SUBRIP_TIMESTAMP_STRING); + + private final StringBuilder textBuilder; + + private final boolean strictParsing; + + public SubripParser() { + this(true); + } + + public SubripParser(boolean strictParsing) { + this.strictParsing = strictParsing; + + textBuilder = new StringBuilder(); + } + + @Override + public SubripSubtitle parse(InputStream inputStream, String inputEncoding, long startTimeUs) + throws IOException { + ArrayList subtitles = new ArrayList<>(); + + // file should not be empty + if (inputStream.available() == 0) { + throw new ParserException("File is empty?"); + } + + BufferedReader subripData = new BufferedReader(new InputStreamReader(inputStream, C.UTF8_NAME)); + String line; + + + // process the cues and text + while ((line = subripData.readLine()) != null) { + long startTime = Cue.UNSET_VALUE; + long endTime = Cue.UNSET_VALUE; + CharSequence text = null; + int position = Cue.UNSET_VALUE; + + Matcher matcher = SUBRIP_POSITION.matcher(line); + if (matcher.matches()) { + position = Integer.parseInt(matcher.group()); + } + + line = subripData.readLine(); + + // parse cue time + matcher = SUBRIP_CUE_IDENTIFIER.matcher(line); + if (!matcher.find()) { + throw new ParserException("Expected cue start time: " + line); + } else { + startTime = parseTimestampUs(matcher.group(1)) + startTimeUs; + endTime = parseTimestampUs(matcher.group(2)) + startTimeUs; + } + + // parse text + textBuilder.setLength(0); + while (((line = subripData.readLine()) != null) && (!line.isEmpty())) { + if (textBuilder.length() > 0) { + textBuilder.append("
"); + } + textBuilder.append(line.trim()); + } + text = Html.fromHtml(textBuilder.toString()); + + SubripCue cue = new SubripCue(startTime, endTime, position, text); + subtitles.add(cue); + } + + subripData.close(); + inputStream.close(); + SubripSubtitle subtitle = new SubripSubtitle(subtitles, startTimeUs); + return subtitle; + } + + @Override + public boolean canParse(String mimeType) { + return MimeTypes.APPLICATION_SUBRIP.equals(mimeType); + } + + private void handleNoncompliantLine(String line) throws ParserException { + if (strictParsing) { + throw new ParserException("Unexpected line: " + line); + } + } + + private static long parseTimestampUs(String s) throws NumberFormatException { + if (!s.matches(SUBRIP_TIMESTAMP_STRING)) { + throw new NumberFormatException("has invalid format"); + } + + String[] parts = s.split(",", 2); + long value = 0; + for (String group : parts[0].split(":")) { + value = value * 60 + Long.parseLong(group); + } + return (value * 1000 + Long.parseLong(parts[1])) * 1000; + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripSubtitle.java b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripSubtitle.java new file mode 100644 index 0000000000..27cf7988ea --- /dev/null +++ b/library/src/main/java/com/google/android/exoplayer/text/subrip/SubripSubtitle.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2014 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 com.google.android.exoplayer.text.subrip; + +import android.text.SpannableStringBuilder; + +import com.google.android.exoplayer.text.Cue; +import com.google.android.exoplayer.text.Subtitle; +import com.google.android.exoplayer.util.Assertions; +import com.google.android.exoplayer.util.Util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * A representation of a SubRip subtitle. + */ +/* package */ final class SubripSubtitle implements Subtitle { + + private final List cues; + private final int numCues; + private final long startTimeUs; + private final long[] cueTimesUs; + private final long[] sortedCueTimesUs; + + /** + * @param cues A list of the cues in this subtitle. + * @param startTimeUs The start time of the subtitle. + */ + public SubripSubtitle(List cues, long startTimeUs) { + this.cues = cues; + numCues = cues.size(); + this.startTimeUs = startTimeUs; + + this.cueTimesUs = new long[2 * numCues]; + for (int cueIndex = 0; cueIndex < numCues; cueIndex++) { + SubripCue cue = cues.get(cueIndex); + int arrayIndex = cueIndex * 2; + cueTimesUs[arrayIndex] = cue.startTime; + cueTimesUs[arrayIndex + 1] = cue.endTime; + } + + this.sortedCueTimesUs = Arrays.copyOf(cueTimesUs, cueTimesUs.length); + Arrays.sort(sortedCueTimesUs); + } + + @Override + public long getStartTime() { + return startTimeUs; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + Assertions.checkArgument(timeUs >= 0); + int index = Util.binarySearchCeil(sortedCueTimesUs, timeUs, false, false); + return index < sortedCueTimesUs.length ? index : -1; + } + + @Override + public int getEventTimeCount() { + return sortedCueTimesUs.length; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < sortedCueTimesUs.length); + return sortedCueTimesUs[index]; + } + + @Override + public long getLastEventTime() { + if (getEventTimeCount() == 0) { + return -1; + } + return sortedCueTimesUs[sortedCueTimesUs.length - 1]; + } + + @Override + public List getCues(long timeUs) { + ArrayList list = null; + SubripCue firstNormalCue = null; + SpannableStringBuilder normalCueTextBuilder = null; + + for (int i = 0; i < numCues; i++) { + if ((cueTimesUs[i * 2] <= timeUs) && (timeUs < cueTimesUs[i * 2 + 1])) { + if (list == null) { + list = new ArrayList<>(); + } + SubripCue cue = cues.get(i); + if (cue.isNormalCue()) { + // we want to merge all of the normal cues into a single cue to ensure they are drawn + // correctly (i.e. don't overlap) and to emulate roll-up, but only if there are multiple + // normal cues, otherwise we can just append the single normal cue + if (firstNormalCue == null) { + firstNormalCue = cue; + } else if (normalCueTextBuilder == null) { + normalCueTextBuilder = new SpannableStringBuilder(); + normalCueTextBuilder.append(firstNormalCue.text).append("\n").append(cue.text); + } else { + normalCueTextBuilder.append("\n").append(cue.text); + } + } else { + list.add(cue); + } + } + } + if (normalCueTextBuilder != null) { + // there were multiple normal cues, so create a new cue with all of the text + list.add(new SubripCue(normalCueTextBuilder)); + } else if (firstNormalCue != null) { + // there was only a single normal cue, so just add it to the list + list.add(firstNormalCue); + } + + if (list != null) { + return list; + } else { + return Collections.emptyList(); + } + } + +} diff --git a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java index 3a6ea01c6f..393421e57b 100644 --- a/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java +++ b/library/src/main/java/com/google/android/exoplayer/util/MimeTypes.java @@ -55,6 +55,7 @@ public class MimeTypes { public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; public static final String APPLICATION_EIA608 = BASE_TYPE_APPLICATION + "/eia-608"; + public static final String APPLICATION_SUBRIP = BASE_TYPE_APPLICATION + "/x-subrip"; public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; diff --git a/library/src/test/assets/subrip/empty b/library/src/test/assets/subrip/empty new file mode 100644 index 0000000000..e69de29bb2 diff --git a/library/src/test/assets/subrip/typical b/library/src/test/assets/subrip/typical new file mode 100644 index 0000000000..800b0678c5 --- /dev/null +++ b/library/src/test/assets/subrip/typical @@ -0,0 +1,8 @@ +1 +00:00:00,000 --> 00:00:01,234 +This is the first subtitle. + +2 +00:00:02,345 --> 00:00:03,456 +This is the second subtitle. +Second subtitle with second line. \ No newline at end of file diff --git a/library/src/test/java/com/google/android/exoplayer/text/subrip/SubripParserTest.java b/library/src/test/java/com/google/android/exoplayer/text/subrip/SubripParserTest.java new file mode 100644 index 0000000000..0735d023f6 --- /dev/null +++ b/library/src/test/java/com/google/android/exoplayer/text/subrip/SubripParserTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2014 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 com.google.android.exoplayer.text.subrip; + +import android.test.InstrumentationTestCase; + +import com.google.android.exoplayer.C; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Unit test for {@link SubripParser}. + */ +public class SubripParserTest extends InstrumentationTestCase { + + private static final String TYPICAL_SUBRIP_FILE = "subrip/typical"; + private static final String EMPTY_SUBRIP_FILE = "subrip/empty"; + + public void testParseNullSubripFile() throws IOException { + SubripParser parser = new SubripParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets().open(EMPTY_SUBRIP_FILE); + + try { + parser.parse(inputStream, C.UTF8_NAME, 0); + fail("Expected IOException"); + } catch (IOException expected) { + // Do nothing. + } + } + + public void testParseTypicalSubripFile() throws IOException { + SubripParser parser = new SubripParser(); + InputStream inputStream = + getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_SUBRIP_FILE); + SubripSubtitle subtitle = parser.parse(inputStream, C.UTF8_NAME, 0); + + // test start time and event count + long startTimeUs = 0; + assertEquals(startTimeUs, subtitle.getStartTime()); + assertEquals(4, subtitle.getEventTimeCount()); + + // test first cue + assertEquals(startTimeUs, subtitle.getEventTime(0)); + assertEquals("This is the first subtitle.", + subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); + assertEquals(startTimeUs + 1234000, subtitle.getEventTime(1)); + + // test second cue + assertEquals(startTimeUs + 2345000, subtitle.getEventTime(2)); + assertEquals("This is the second subtitle.\nSecond subtitle with second line.", + subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); + assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3)); + } + +}