From e4e02f91891f1b6b944e2b7b5ade5ae06f47f913 Mon Sep 17 00:00:00 2001 From: Oliver Woodman Date: Mon, 28 Sep 2015 12:20:27 +0100 Subject: [PATCH] Further improve WebVTT parser according to WebVTT spec --- .../androidTest/assets/webvtt/live_typical | 7 - .../assets/webvtt/typical_with_comments | 4 +- ...typical_with_metadata => with_positioning} | 12 +- .../webvtt/{typical_with_tags => with_tags} | 0 .../text/webvtt/WebvttParserTest.java | 249 ++++++---------- .../google/android/exoplayer/text/Cue.java | 107 ++++++- .../android/exoplayer/text/CuePainter.java | 72 +++-- .../exoplayer/text/SubtitleLayout.java | 4 +- .../exoplayer/text/webvtt/WebvttCue.java | 13 +- .../exoplayer/text/webvtt/WebvttParser.java | 273 ++++++++++-------- 10 files changed, 408 insertions(+), 333 deletions(-) delete mode 100644 library/src/androidTest/assets/webvtt/live_typical rename library/src/androidTest/assets/webvtt/{typical_with_metadata => with_positioning} (69%) rename library/src/androidTest/assets/webvtt/{typical_with_tags => with_tags} (100%) diff --git a/library/src/androidTest/assets/webvtt/live_typical b/library/src/androidTest/assets/webvtt/live_typical deleted file mode 100644 index ec791f357a..0000000000 --- a/library/src/androidTest/assets/webvtt/live_typical +++ /dev/null @@ -1,7 +0,0 @@ -WEBVTT - -00:00.000 --> 00:01.234 -This is the first subtitle. - -00:02.345 --> 00:03.456 -This is the second subtitle. diff --git a/library/src/androidTest/assets/webvtt/typical_with_comments b/library/src/androidTest/assets/webvtt/typical_with_comments index 6d116e2db3..9b75c00945 100644 --- a/library/src/androidTest/assets/webvtt/typical_with_comments +++ b/library/src/androidTest/assets/webvtt/typical_with_comments @@ -8,7 +8,9 @@ with multiple lines 00:00.000 --> 00:01.234 This is the first subtitle. -NOTE Single line comment +NOTE Single line comment with a space + +NOTE Single line comment with a tab 2 00:02.345 --> 00:03.456 diff --git a/library/src/androidTest/assets/webvtt/typical_with_metadata b/library/src/androidTest/assets/webvtt/with_positioning similarity index 69% rename from library/src/androidTest/assets/webvtt/typical_with_metadata rename to library/src/androidTest/assets/webvtt/with_positioning index 61fa0cc690..e50163a64c 100644 --- a/library/src/androidTest/assets/webvtt/typical_with_metadata +++ b/library/src/androidTest/assets/webvtt/with_positioning @@ -16,7 +16,13 @@ NOTE Line as percentage and line alignment 00:04.000 --> 00:05.000 line:45%,end align:middle size:35% This is the third subtitle. -NOTE Line as absolute negative number and without line alignment +NOTE Line as absolute negative number and without line alignment. + +00:06.000 --> 00:07.000 line:-10 align:middle +This is the fourth subtitle. + +NOTE The positioning alignment should be inherited from align. + +00:07.000 --> 00:08.000 position:10% align:end size:10% +This is the fifth subtitle. -00:06.000 --> 00:07.000 line:-10 align:middle size:35% -This is the forth subtitle. diff --git a/library/src/androidTest/assets/webvtt/typical_with_tags b/library/src/androidTest/assets/webvtt/with_tags similarity index 100% rename from library/src/androidTest/assets/webvtt/typical_with_tags rename to library/src/androidTest/assets/webvtt/with_tags diff --git a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java index 9b643a5b36..b0d7271c9c 100644 --- a/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java +++ b/library/src/androidTest/java/com/google/android/exoplayer/text/webvtt/WebvttParserTest.java @@ -15,32 +15,31 @@ */ package com.google.android.exoplayer.text.webvtt; -import android.test.InstrumentationTestCase; -import android.text.Layout; - import com.google.android.exoplayer.text.Cue; +import android.test.InstrumentationTestCase; +import android.text.Layout.Alignment; + import java.io.IOException; import java.io.InputStream; +import java.util.List; /** * Unit test for {@link WebvttParser}. */ public class WebvttParserTest extends InstrumentationTestCase { - private static final String TYPICAL_WEBVTT_FILE = "webvtt/typical"; - private static final String TYPICAL_WITH_IDS_WEBVTT_FILE = "webvtt/typical_with_identifiers"; - private static final String TYPICAL_WITH_TAGS_WEBVTT_FILE = "webvtt/typical_with_tags"; - private static final String TYPICAL_WITH_COMMENTS_WEBVTT_FILE = "webvtt/typical_with_comments"; - private static final String TYPICAL_WITH_METADATA_WEBVTT_FILE = "webvtt/typical_with_metadata"; - private static final String LIVE_TYPICAL_WEBVTT_FILE = "webvtt/live_typical"; - private static final String EMPTY_WEBVTT_FILE = "webvtt/empty"; + private static final String TYPICAL_FILE = "webvtt/typical"; + private static final String TYPICAL_WITH_IDS_FILE = "webvtt/typical_with_identifiers"; + private static final String TYPICAL_WITH_COMMENTS_FILE = "webvtt/typical_with_comments"; + private static final String WITH_POSITIONING_FILE = "webvtt/with_positioning"; + private static final String WITH_TAGS_FILE = "webvtt/with_tags"; + private static final String EMPTY_FILE = "webvtt/empty"; - public void testParseNullWebvttFile() throws IOException { + public void testParseEmpty() throws IOException { WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets().open(EMPTY_WEBVTT_FILE); - + InputStream inputStream = getInstrumentation().getContext().getResources().getAssets() + .open(EMPTY_FILE); try { parser.parse(inputStream); fail("Expected IOException"); @@ -49,189 +48,113 @@ public class WebvttParserTest extends InstrumentationTestCase { } } - public void testParseTypicalWebvttFile() throws IOException { + public void testParseTypical() throws IOException { WebvttParser parser = new WebvttParser(); InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_WEBVTT_FILE); + getInstrumentation().getContext().getResources().getAssets().open(TYPICAL_FILE); WebvttSubtitle subtitle = parser.parse(inputStream); // test event count assertEquals(4, subtitle.getEventTimeCount()); - // test first cue - assertEquals(0, subtitle.getEventTime(0)); - assertEquals("This is the first subtitle.", - subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - assertEquals(1234000, subtitle.getEventTime(1)); - - // test second cue - assertEquals(2345000, subtitle.getEventTime(2)); - assertEquals("This is the second subtitle.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(3456000, subtitle.getEventTime(3)); + // test cues + assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle."); + assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle."); } - public void testParseTypicalWithIdsWebvttFile() throws IOException { + public void testParseTypicalWithIds() throws IOException { WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets() - .open(TYPICAL_WITH_IDS_WEBVTT_FILE); + InputStream inputStream = getInstrumentation().getContext().getResources().getAssets() + .open(TYPICAL_WITH_IDS_FILE); WebvttSubtitle subtitle = parser.parse(inputStream); // test event count assertEquals(4, subtitle.getEventTimeCount()); - // test first cue - assertEquals(0, subtitle.getEventTime(0)); - assertEquals("This is the first subtitle.", - subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - assertEquals(1234000, subtitle.getEventTime(1)); - - // test second cue - assertEquals(2345000, subtitle.getEventTime(2)); - assertEquals("This is the second subtitle.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(3456000, subtitle.getEventTime(3)); + // test cues + assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle."); + assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle."); } - public void testParseTypicalWithTagsWebvttFile() throws IOException { + public void testParseTypicalWithComments() throws IOException { WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets() - .open(TYPICAL_WITH_TAGS_WEBVTT_FILE); + InputStream inputStream = getInstrumentation().getContext().getResources().getAssets() + .open(TYPICAL_WITH_COMMENTS_FILE); + WebvttSubtitle subtitle = parser.parse(inputStream); + + // test event count + assertEquals(4, subtitle.getEventTimeCount()); + + // test cues + assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle."); + assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle."); + } + + public void testParseWithTags() throws IOException { + WebvttParser parser = new WebvttParser(); + InputStream inputStream = getInstrumentation().getContext().getResources().getAssets() + .open(WITH_TAGS_FILE); WebvttSubtitle subtitle = parser.parse(inputStream); // test event count assertEquals(8, subtitle.getEventTimeCount()); - // test first cue - assertEquals(0, subtitle.getEventTime(0)); - assertEquals("This is the first subtitle.", - subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - assertEquals(1234000, subtitle.getEventTime(1)); - - // test second cue - assertEquals(2345000, subtitle.getEventTime(2)); - assertEquals("This is the second subtitle.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(3456000, subtitle.getEventTime(3)); - - // test third cue - assertEquals(4000000, subtitle.getEventTime(4)); - assertEquals("This is the third subtitle.", - subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString()); - assertEquals(5000000, subtitle.getEventTime(5)); - - // test fourth cue - assertEquals(6000000, subtitle.getEventTime(6)); - assertEquals("This is the &subtitle.", - subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString()); - assertEquals(7000000, subtitle.getEventTime(7)); + // test cues + assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle."); + assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle."); + assertCue(subtitle, 4, 4000000, 5000000, "This is the third subtitle."); + assertCue(subtitle, 6, 6000000, 7000000, "This is the &subtitle."); } - public void testParseTypicalWithCommentsWebvttFile() throws IOException { + public void testParseWithPositioning() throws IOException { WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets() - .open(TYPICAL_WITH_COMMENTS_WEBVTT_FILE); + InputStream inputStream = getInstrumentation().getContext().getResources().getAssets() + .open(WITH_POSITIONING_FILE); WebvttSubtitle subtitle = parser.parse(inputStream); // test event count - assertEquals(4, subtitle.getEventTimeCount()); + assertEquals(10, subtitle.getEventTimeCount()); - // test first cue - assertEquals(0, subtitle.getEventTime(0)); - assertEquals("This is the first subtitle.", - subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - assertEquals(1234000, subtitle.getEventTime(1)); - - // test second cue - assertEquals(2345000, subtitle.getEventTime(2)); - assertEquals("This is the second subtitle.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(3456000, subtitle.getEventTime(3)); + // test cues + assertCue(subtitle, 0, 0, 1234000, "This is the first subtitle.", Alignment.ALIGN_NORMAL, + Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f, Cue.ANCHOR_TYPE_START, 0.35f); + assertCue(subtitle, 2, 2345000, 3456000, "This is the second subtitle.", + Alignment.ALIGN_OPPOSITE, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, + Cue.TYPE_UNSET, 0.35f); + assertCue(subtitle, 4, 4000000, 5000000, "This is the third subtitle.", + Alignment.ALIGN_CENTER, 0.45f, Cue.LINE_TYPE_FRACTION, Cue.ANCHOR_TYPE_END, Cue.DIMEN_UNSET, + Cue.TYPE_UNSET, 0.35f); + assertCue(subtitle, 6, 6000000, 7000000, "This is the fourth subtitle.", + Alignment.ALIGN_CENTER, -10f, Cue.LINE_TYPE_NUMBER, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, + Cue.TYPE_UNSET, Cue.DIMEN_UNSET); + assertCue(subtitle, 8, 7000000, 8000000, "This is the fifth subtitle.", + Alignment.ALIGN_OPPOSITE, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, 0.1f, + Cue.ANCHOR_TYPE_END, 0.1f); } - public void testParseTypicalWithMetadataWebvttFile() throws IOException { - WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets() - .open(TYPICAL_WITH_METADATA_WEBVTT_FILE); - WebvttSubtitle subtitle = parser.parse(inputStream); - - // test event count - assertEquals(8, subtitle.getEventTimeCount()); - - // test first cue - assertEquals(0, subtitle.getEventTime(0)); - assertEquals("This is the first subtitle.", - subtitle.getCues(subtitle.getEventTime(0)).get(0).text.toString()); - assertEquals(10, - subtitle.getCues(subtitle.getEventTime(0)).get(0).position); - assertEquals(Layout.Alignment.ALIGN_NORMAL, - subtitle.getCues(subtitle.getEventTime(0)).get(0).alignment); - assertEquals(35, - subtitle.getCues(subtitle.getEventTime(0)).get(0).size); - assertEquals(1234000, subtitle.getEventTime(1)); - - // test second cue - assertEquals(2345000, subtitle.getEventTime(2)); - assertEquals("This is the second subtitle.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(Cue.UNSET_VALUE, - subtitle.getCues(subtitle.getEventTime(2)).get(0).position); - assertEquals(Layout.Alignment.ALIGN_OPPOSITE, - subtitle.getCues(subtitle.getEventTime(2)).get(0).alignment); - assertEquals(35, - subtitle.getCues(subtitle.getEventTime(2)).get(0).size); - assertEquals(3456000, subtitle.getEventTime(3)); - - // test third cue - assertEquals(4000000, subtitle.getEventTime(4)); - assertEquals("This is the third subtitle.", - subtitle.getCues(subtitle.getEventTime(4)).get(0).text.toString()); - assertEquals(45, - subtitle.getCues(subtitle.getEventTime(4)).get(0).line); - assertEquals(Layout.Alignment.ALIGN_CENTER, - subtitle.getCues(subtitle.getEventTime(4)).get(0).alignment); - assertEquals(35, - subtitle.getCues(subtitle.getEventTime(4)).get(0).size); - assertEquals(5000000, subtitle.getEventTime(5)); - - // test forth cue - assertEquals(6000000, subtitle.getEventTime(6)); - assertEquals("This is the forth subtitle.", - subtitle.getCues(subtitle.getEventTime(6)).get(0).text.toString()); - assertEquals(-10, - subtitle.getCues(subtitle.getEventTime(6)).get(0).line); - assertEquals(Layout.Alignment.ALIGN_CENTER, - subtitle.getCues(subtitle.getEventTime(6)).get(0).alignment); - assertEquals(35, - subtitle.getCues(subtitle.getEventTime(6)).get(0).size); - assertEquals(7000000, subtitle.getEventTime(7)); + private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs, + int endTimeUs, String text) { + assertCue(subtitle, eventTimeIndex, startTimeUs, endTimeUs, text, null, Cue.DIMEN_UNSET, + Cue.TYPE_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET); } - public void testParseLiveTypicalWebvttFile() throws IOException { - WebvttParser parser = new WebvttParser(); - InputStream inputStream = - getInstrumentation().getContext().getResources().getAssets().open(LIVE_TYPICAL_WEBVTT_FILE); - WebvttSubtitle subtitle = parser.parse(inputStream); - - // test event count - long startTimeUs = 0; - 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.", - subtitle.getCues(subtitle.getEventTime(2)).get(0).text.toString()); - assertEquals(startTimeUs + 3456000, subtitle.getEventTime(3)); + private static void assertCue(WebvttSubtitle subtitle, int eventTimeIndex, long startTimeUs, + int endTimeUs, String text, Alignment textAlignment, float line, int lineType, int lineAnchor, + float position, int positionAnchor, float size) { + assertEquals(startTimeUs, subtitle.getEventTime(eventTimeIndex)); + assertEquals(endTimeUs, subtitle.getEventTime(eventTimeIndex + 1)); + List cues = subtitle.getCues(subtitle.getEventTime(eventTimeIndex)); + assertEquals(1, cues.size()); + // Assert cue properties + Cue cue = cues.get(0); + assertEquals(text, cue.text.toString()); + assertEquals(textAlignment, cue.textAlignment); + assertEquals(line, cue.line); + assertEquals(lineType, cue.lineType); + assertEquals(lineAnchor, cue.lineAnchor); + assertEquals(position, cue.position); + assertEquals(positionAnchor, cue.positionAnchor); + assertEquals(size, cue.size); } } diff --git a/library/src/main/java/com/google/android/exoplayer/text/Cue.java b/library/src/main/java/com/google/android/exoplayer/text/Cue.java index f9476b5a5a..33f37ef608 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/Cue.java +++ b/library/src/main/java/com/google/android/exoplayer/text/Cue.java @@ -23,30 +23,117 @@ import android.text.Layout.Alignment; public class Cue { /** - * Used by some methods to indicate that no value is set. + * An unset position or width. */ - public static final int UNSET_VALUE = -1; + public static final float DIMEN_UNSET = Float.MIN_VALUE; + /** + * An unset anchor or line type value. + */ + public static final int TYPE_UNSET = Integer.MIN_VALUE; + /** + * Anchors the left (for horizontal positions) or top (for vertical positions) edge of the cue + * box. + */ + public static final int ANCHOR_TYPE_START = 0; + /** + * Anchors the middle of the cue box. + */ + public static final int ANCHOR_TYPE_MIDDLE = 1; + /** + * Anchors the right (for horizontal positions) or bottom (for vertical positions) edge of the cue + * box. + */ + public static final int ANCHOR_TYPE_END = 2; + /** + * Value for {@link #lineType} when {@link #line} is a fractional position. + */ + public static final int LINE_TYPE_FRACTION = 0; + /** + * Value for {@link #lineType} when {@link #line} is a line number. + */ + public static final int LINE_TYPE_NUMBER = 1; + /** + * The cue text. Note the {@link CharSequence} may be decorated with styling spans. + */ public final CharSequence text; - - public final int line; - public final int position; - public final Alignment alignment; - public final int size; + /** + * The alignment of the cue text within the cue box. + */ + public final Alignment textAlignment; + /** + * The position of the {@link #lineAnchor} of the cue box within the viewport in the direction + * orthogonal to the writing direction, or {@link #DIMEN_UNSET}. When set, the interpretation of + * the value depends on the value of {@link #lineType}. + *

+ * For horizontal text and {@link #lineType} equal to {@link #LINE_TYPE_FRACTION}, this is the + * fractional vertical position relative to the top of the viewport. + */ + public final float line; + /** + * The type of the {@link #line} value. + *

+ * {@link #LINE_TYPE_FRACTION} indicates that {@link #line} is a fractional position within the + * viewport. + *

+ * {@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a line number, where the size of each + * line is taken to be the size of the first line of the cue. When {@link #line} is greater than + * or equal to 0, lines count from the start of the viewport (the first line is numbered 0). When + * {@link #line} is negative, lines count from the end of the viewport (the last line is numbered + * -1). For horizontal text the size of the first line of the cue is its height, and the start + * and end of the viewport are the top and bottom respectively. + */ + public final int lineType; + /** + * The cue box anchor positioned by {@link #line}. One of {@link #ANCHOR_TYPE_START}, + * {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + *

+ * For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link #ANCHOR_TYPE_MIDDLE} + * and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of the cue box + * respectively. + */ + public final int lineAnchor; + /** + * The fractional position of the {@link #positionAnchor} of the cue box within the viewport in + * the direction orthogonal to {@link #line}, or {@link #DIMEN_UNSET}. + *

+ * For horizontal text, this is the horizontal position relative to the left of the viewport. Note + * that positioning is relative to the left of the viewport even in the case of right-to-left + * text. + */ + public final float position; + /** + * The cue box anchor positioned by {@link #position}. One of {@link #ANCHOR_TYPE_START}, + * {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + *

+ * For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link #ANCHOR_TYPE_MIDDLE} + * and {@link #ANCHOR_TYPE_END} correspond to the left, middle and right of the cue box + * respectively. + */ + public final int positionAnchor; + /** + * The size of the cue box in the writing direction specified as a fraction of the viewport size + * in that direction, or {@link #DIMEN_UNSET}. + */ + public final float size; public Cue() { this(null); } public Cue(CharSequence text) { - this(text, UNSET_VALUE, UNSET_VALUE, null, UNSET_VALUE); + this(text, null, DIMEN_UNSET, TYPE_UNSET, TYPE_UNSET, DIMEN_UNSET, TYPE_UNSET, DIMEN_UNSET); } - public Cue(CharSequence text, int line, int position, Alignment alignment, int size) { + public Cue(CharSequence text, Alignment textAlignment, float line, int lineType, + int lineAnchor, float position, int positionAnchor, float size) { this.text = text; + this.textAlignment = textAlignment; this.line = line; + this.lineType = lineType; + this.lineAnchor = lineAnchor; this.position = position; - this.alignment = alignment; + this.positionAnchor = positionAnchor; this.size = size; } diff --git a/library/src/main/java/com/google/android/exoplayer/text/CuePainter.java b/library/src/main/java/com/google/android/exoplayer/text/CuePainter.java index 06dbcf235a..a86edd376a 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/CuePainter.java +++ b/library/src/main/java/com/google/android/exoplayer/text/CuePainter.java @@ -63,8 +63,13 @@ import android.util.Log; // Previous input variables. private CharSequence cueText; - private int cuePosition; - private Alignment cueAlignment; + private Alignment cueTextAlignment; + private float cueLine; + private int cueLineType; + private int cueLineAnchor; + private float cuePosition; + private int cuePositionAnchor; + private float cueSize; private boolean applyEmbeddedStyles; private int foregroundColor; private int backgroundColor; @@ -120,7 +125,7 @@ import android.util.Log; * @param style The style to use when drawing the cue text. * @param textSizePx The text size to use when drawing the cue text, in pixels. * @param bottomPaddingFraction The bottom padding fraction to apply when {@link Cue#line} is - * {@link Cue#UNSET_VALUE}, as a fraction of the viewport height + * {@link Cue#DIMEN_UNSET}, as a fraction of the viewport height * @param canvas The canvas into which to draw. * @param cueBoxLeft The left position of the enclosing cue box. * @param cueBoxTop The top position of the enclosing cue box. @@ -140,8 +145,13 @@ import android.util.Log; cueText = cueText.toString(); } if (areCharSequencesEqual(this.cueText, cueText) + && Util.areEqual(this.cueTextAlignment, cue.textAlignment) + && this.cueLine == cue.line + && this.cueLineType == cue.lineType + && Util.areEqual(this.cueLineAnchor, cue.lineAnchor) && this.cuePosition == cue.position - && Util.areEqual(this.cueAlignment, cue.alignment) + && Util.areEqual(this.cuePositionAnchor, cue.positionAnchor) + && this.cueSize == cue.size && this.applyEmbeddedStyles == applyEmbeddedStyles && this.foregroundColor == style.foregroundColor && this.backgroundColor == style.backgroundColor @@ -161,8 +171,13 @@ import android.util.Log; } this.cueText = cueText; + this.cueTextAlignment = cue.textAlignment; + this.cueLine = cue.line; + this.cueLineType = cue.lineType; + this.cueLineAnchor = cue.lineAnchor; this.cuePosition = cue.position; - this.cueAlignment = cue.alignment; + this.cuePositionAnchor = cue.positionAnchor; + this.cueSize = cue.size; this.applyEmbeddedStyles = applyEmbeddedStyles; this.foregroundColor = style.foregroundColor; this.backgroundColor = style.backgroundColor; @@ -182,16 +197,19 @@ import android.util.Log; textPaint.setTextSize(textSizePx); int textPaddingX = (int) (textSizePx * INNER_PADDING_RATIO + 0.5f); + int availableWidth = parentWidth - textPaddingX * 2; + if (cueSize != Cue.DIMEN_UNSET) { + availableWidth = (int) (availableWidth * cueSize); + } if (availableWidth <= 0) { Log.w(TAG, "Skipped drawing subtitle cue (insufficient space)"); return; } - Alignment layoutAlignment = cueAlignment == null ? Alignment.ALIGN_CENTER : cueAlignment; - textLayout = new StaticLayout(cueText, textPaint, availableWidth, layoutAlignment, spacingMult, + Alignment textAlignment = cueTextAlignment == null ? Alignment.ALIGN_CENTER : cueTextAlignment; + textLayout = new StaticLayout(cueText, textPaint, availableWidth, textAlignment, spacingMult, spacingAdd, true); - int textHeight = textLayout.getHeight(); int textWidth = 0; int lineCount = textLayout.getLineCount(); @@ -202,14 +220,13 @@ import android.util.Log; int textLeft; int textRight; - if (cue.position != Cue.UNSET_VALUE) { - if (cue.alignment == Alignment.ALIGN_OPPOSITE) { - textRight = (parentWidth * cue.position) / 100 + parentLeft; - textLeft = Math.max(textRight - textWidth, parentLeft); - } else { - textLeft = (parentWidth * cue.position) / 100 + parentLeft; - textRight = Math.min(textLeft + textWidth, parentRight); - } + if (cuePosition != Cue.DIMEN_UNSET) { + int anchorPosition = Math.round(parentWidth * cuePosition) + parentLeft; + textLeft = cuePositionAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textWidth + : cuePositionAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textWidth) / 2 + : anchorPosition; + textLeft = Math.max(textLeft, parentLeft); + textRight = Math.min(textLeft + textWidth, parentRight); } else { textLeft = (parentWidth - textWidth) / 2; textRight = textLeft + textWidth; @@ -217,12 +234,29 @@ import android.util.Log; int textTop; int textBottom; - if (cue.line != Cue.UNSET_VALUE) { - textTop = (parentHeight * cue.line) / 100 + parentTop; + if (cueLine != Cue.DIMEN_UNSET) { + int anchorPosition; + if (cueLineType == Cue.LINE_TYPE_FRACTION) { + anchorPosition = Math.round(parentHeight * cueLine) + parentTop; + } else { + // cueLineType == Cue.LINE_TYPE_NUMBER + int firstLineHeight = textLayout.getLineBottom(0) - textLayout.getLineTop(0); + if (cueLine >= 0) { + anchorPosition = Math.round(cueLine * firstLineHeight) + parentTop; + } else { + anchorPosition = Math.round(cueLine * firstLineHeight) + parentBottom; + } + } + textTop = cueLineAnchor == Cue.ANCHOR_TYPE_END ? anchorPosition - textHeight + : cueLineAnchor == Cue.ANCHOR_TYPE_MIDDLE ? (anchorPosition * 2 - textHeight) / 2 + : anchorPosition; textBottom = textTop + textHeight; if (textBottom > parentBottom) { textTop = parentBottom - textHeight; textBottom = parentBottom; + } else if (textTop < parentTop) { + textTop = parentTop; + textBottom = parentTop + textHeight; } } else { textTop = parentBottom - textHeight - (int) (parentHeight * bottomPaddingFraction); @@ -232,7 +266,7 @@ import android.util.Log; textWidth = textRight - textLeft; // Update the derived drawing variables. - this.textLayout = new StaticLayout(cueText, textPaint, textWidth, layoutAlignment, spacingMult, + this.textLayout = new StaticLayout(cueText, textPaint, textWidth, textAlignment, spacingMult, spacingAdd, true); this.textLeft = textLeft; this.textTop = textTop; diff --git a/library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java b/library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java index 08ddc551bd..700a72138c 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java +++ b/library/src/main/java/com/google/android/exoplayer/text/SubtitleLayout.java @@ -38,7 +38,7 @@ public final class SubtitleLayout extends View { public static final float DEFAULT_TEXT_SIZE_FRACTION = 0.0533f; /** - * The default bottom padding to apply when {@link Cue#line} is {@link Cue#UNSET_VALUE}, as a + * The default bottom padding to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET}, as a * fraction of the viewport height. * * @see #setBottomPaddingFraction(float) @@ -174,7 +174,7 @@ public final class SubtitleLayout extends View { } /** - * Sets the bottom padding fraction to apply when {@link Cue#line} is {@link Cue#UNSET_VALUE}, + * Sets the bottom padding fraction to apply when {@link Cue#line} is {@link Cue#DIMEN_UNSET}, * as a fraction of the view's remaining height after its top and bottom padding have been * subtracted. *

diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java index 1d6d3c554a..3b0d233007 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttCue.java @@ -28,16 +28,17 @@ import android.text.Layout.Alignment; public final long endTime; public WebvttCue(CharSequence text) { - this(Cue.UNSET_VALUE, Cue.UNSET_VALUE, text); + this(0, 0, text); } public WebvttCue(long startTime, long endTime, CharSequence text) { - this(startTime, endTime, text, Cue.UNSET_VALUE, Cue.UNSET_VALUE, null, Cue.UNSET_VALUE); + this(startTime, endTime, text, null, Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.TYPE_UNSET, + Cue.DIMEN_UNSET, Cue.TYPE_UNSET, Cue.DIMEN_UNSET); } - public WebvttCue(long startTime, long endTime, CharSequence text, int line, int position, - Alignment alignment, int size) { - super(text, line, position, alignment, size); + public WebvttCue(long startTime, long endTime, CharSequence text, Alignment textAlignment, + float line, int lineType, int lineAnchor, float position, int positionAnchor, float width) { + super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, width); this.startTime = startTime; this.endTime = endTime; } @@ -49,7 +50,7 @@ import android.text.Layout.Alignment; * @return True if this cue should be placed in the default position; false otherwise. */ public boolean isNormalCue() { - return (line == UNSET_VALUE && position == UNSET_VALUE); + return (line == DIMEN_UNSET && position == DIMEN_UNSET); } } diff --git a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java index 03d7ae98dc..53510d257b 100644 --- a/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java +++ b/library/src/main/java/com/google/android/exoplayer/text/webvtt/WebvttParser.java @@ -24,7 +24,6 @@ import com.google.android.exoplayer.util.MimeTypes; import android.text.Html; import android.text.Layout.Alignment; import android.util.Log; -import android.util.Pair; import java.io.BufferedReader; import java.io.IOException; @@ -43,34 +42,15 @@ public final class WebvttParser implements SubtitleParser { private static final String TAG = "WebvttParser"; - private static final String WEBVTT_FILE_HEADER_STRING = "^\uFEFF?WEBVTT((\u0020|\u0009).*)?$"; - private static final Pattern WEBVTT_FILE_HEADER = - Pattern.compile(WEBVTT_FILE_HEADER_STRING); - - private static final String WEBVTT_COMMENT_BLOCK_STRING = "^NOTE((\u0020|\u0009).*)?$"; - private static final Pattern WEBVTT_COMMENT_BLOCK = - Pattern.compile(WEBVTT_COMMENT_BLOCK_STRING); - - private static final String WEBVTT_METADATA_HEADER_STRING = "\\S*[:=]\\S*"; - private static final Pattern WEBVTT_METADATA_HEADER = - Pattern.compile(WEBVTT_METADATA_HEADER_STRING); - - private static final String WEBVTT_CUE_IDENTIFIER_STRING = "^(?!.*(-->)).*$"; - private static final Pattern WEBVTT_CUE_IDENTIFIER = - Pattern.compile(WEBVTT_CUE_IDENTIFIER_STRING); - - private static final String WEBVTT_TIMESTAMP_STRING = "(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}"; - private static final Pattern WEBVTT_TIMESTAMP = Pattern.compile(WEBVTT_TIMESTAMP_STRING); - - private static final String WEBVTT_CUE_SETTING_STRING = "\\S*:\\S*"; - private static final Pattern WEBVTT_CUE_SETTING = Pattern.compile(WEBVTT_CUE_SETTING_STRING); - - private static final String WEBVTT_PERCENTAGE_NUMBER_STRING = "^([0-9]+|[0-9]+\\.[0-9]+)$"; - - private static final String NON_NUMERIC_STRING = ".*[^0-9].*"; + private static final Pattern HEADER = Pattern.compile("^\uFEFF?WEBVTT((\u0020|\u0009).*)?$"); + private static final Pattern COMMENT_BLOCK = Pattern.compile("^NOTE((\u0020|\u0009).*)?$"); + private static final Pattern METADATA_HEADER = Pattern.compile("\\S*[:=]\\S*"); + private static final Pattern CUE_IDENTIFIER = Pattern.compile("^(?!.*(-->)).*$"); + private static final Pattern TIMESTAMP = Pattern.compile("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}"); + private static final Pattern CUE_SETTING = Pattern.compile("\\S*:\\S*"); + private final PositionHolder positionHolder; private final StringBuilder textBuilder; - private final boolean strictParsing; /** @@ -88,6 +68,7 @@ public final class WebvttParser implements SubtitleParser { */ public WebvttParser(boolean strictParsing) { this.strictParsing = strictParsing; + positionHolder = new PositionHolder(); textBuilder = new StringBuilder(); } @@ -100,7 +81,7 @@ public final class WebvttParser implements SubtitleParser { // file should start with "WEBVTT" line = webvttData.readLine(); - if (line == null || !WEBVTT_FILE_HEADER.matcher(line).matches()) { + if (line == null || !HEADER.matcher(line).matches()) { throw new ParserException("Expected WEBVTT. Got " + line); } @@ -116,7 +97,7 @@ public final class WebvttParser implements SubtitleParser { } if (strictParsing) { - Matcher matcher = WEBVTT_METADATA_HEADER.matcher(line); + Matcher matcher = METADATA_HEADER.matcher(line); if (!matcher.find()) { throw new ParserException("Unexpected line: " + line); } @@ -126,38 +107,45 @@ public final class WebvttParser implements SubtitleParser { // process the cues and text while ((line = webvttData.readLine()) != null) { // parse webvtt comment block in case it is present - Matcher matcher = WEBVTT_COMMENT_BLOCK.matcher(line); - if(matcher.find()) { + Matcher matcher = COMMENT_BLOCK.matcher(line); + if (matcher.find()) { // read lines until finding an empty one (webvtt line terminator: CRLF, or LF or CR) - while (((line = webvttData.readLine()) != null) && (!line.isEmpty())) { - // just ignoring comment text + while ((line = webvttData.readLine()) != null && !line.isEmpty()) { + // ignore comment text } continue; } // parse the cue identifier (if present) { - matcher = WEBVTT_CUE_IDENTIFIER.matcher(line); + matcher = CUE_IDENTIFIER.matcher(line); if (matcher.find()) { // ignore the identifier (we currently don't use it) and read the next line line = webvttData.readLine(); + if (line == null) { + // end of file + break; + } } - long startTime = Cue.UNSET_VALUE; - long endTime = Cue.UNSET_VALUE; - CharSequence text = null; - int lineNum = Cue.UNSET_VALUE; - int position = Cue.UNSET_VALUE; - Alignment alignment = null; - int size = Cue.UNSET_VALUE; + long cueStartTime; + long cueEndTime; + CharSequence cueText; + Alignment cueTextAlignment = null; + float cueLine = Cue.DIMEN_UNSET; + int cueLineType = Cue.TYPE_UNSET; + int cueLineAnchor = Cue.TYPE_UNSET; + float cuePosition = Cue.DIMEN_UNSET; + int cuePositionAnchor = Cue.TYPE_UNSET; + float cueWidth = Cue.DIMEN_UNSET; // parse the cue timestamps - matcher = WEBVTT_TIMESTAMP.matcher(line); + matcher = TIMESTAMP.matcher(line); // parse start timestamp if (!matcher.find()) { throw new ParserException("Expected cue start time: " + line); } else { - startTime = parseTimestampUs(matcher.group()); + cueStartTime = parseTimestampUs(matcher.group()); } // parse end timestamp @@ -166,12 +154,12 @@ public final class WebvttParser implements SubtitleParser { throw new ParserException("Expected cue end time: " + line); } else { endTimeString = matcher.group(); - endTime = parseTimestampUs(endTimeString); + cueEndTime = parseTimestampUs(endTimeString); } // parse the (optional) cue setting list line = line.substring(line.indexOf(endTimeString) + endTimeString.length()); - matcher = WEBVTT_CUE_SETTING.matcher(line); + matcher = CUE_SETTING.matcher(line); while (matcher.find()) { String match = matcher.group(); String[] parts = match.split(":", 2); @@ -180,52 +168,44 @@ public final class WebvttParser implements SubtitleParser { try { if ("line".equals(name)) { - Pair lineMetadata = parseLinePositionAttributes(value); - value = lineMetadata.first; - if (value.endsWith("%")) { - lineNum = parseIntPercentage(value); - } else { - // Following WebVTT spec, line number can be a negative number - int sign = 1; - if (value.startsWith("-") && value.length() > 1) { - sign = -1; - value = value.substring(1); - } - - if (value.matches(NON_NUMERIC_STRING)) { - Log.w(TAG, "Invalid line value: " + value); - } else { - lineNum = sign * Integer.parseInt(value); - } - } + parseLineAttribute(value, positionHolder); + cueLine = positionHolder.position; + cueLineType = positionHolder.lineType; + cueLineAnchor = positionHolder.positionAnchor; } else if ("align".equals(name)) { - // TODO: handle for RTL languages - alignment = parseAlignment(value); + cueTextAlignment = parseTextAlignment(value); } else if ("position".equals(name)) { - Pair lineMetadata = parseLinePositionAttributes(value); - value = lineMetadata.first; - position = parseIntPercentage(value); + parsePositionAttribute(value, positionHolder); + cuePosition = positionHolder.position; + cuePositionAnchor = positionHolder.positionAnchor; } else if ("size".equals(name)) { - size = parseIntPercentage(value); + cueWidth = parsePercentage(value); } else { Log.w(TAG, "Unknown cue setting " + name + ":" + value); } } catch (NumberFormatException e) { - Log.w(TAG, name + " contains an invalid value " + value, e); + Log.w(TAG, e.getMessage() + ": " + match); } } + if (cuePosition != Cue.DIMEN_UNSET && cuePositionAnchor == Cue.TYPE_UNSET) { + // Computed position alignment should be derived from the text alignment if it has not been + // set explicitly. + cuePositionAnchor = alignmentToAnchor(cueTextAlignment); + } + // parse text textBuilder.setLength(0); - while (((line = webvttData.readLine()) != null) && (!line.isEmpty())) { + while ((line = webvttData.readLine()) != null && !line.isEmpty()) { if (textBuilder.length() > 0) { textBuilder.append("
"); } textBuilder.append(line.trim()); } - text = Html.fromHtml(textBuilder.toString()); + cueText = Html.fromHtml(textBuilder.toString()); - WebvttCue cue = new WebvttCue(startTime, endTime, text, lineNum, position, alignment, size); + WebvttCue cue = new WebvttCue(cueStartTime, cueEndTime, cueText, cueTextAlignment, cueLine, + cueLineType, cueLineAnchor, cuePosition, cuePositionAnchor, cueWidth); subtitles.add(cue); } @@ -237,67 +217,116 @@ public final class WebvttParser implements SubtitleParser { return MimeTypes.TEXT_VTT.equals(mimeType); } - private static int parseIntPercentage(String s) throws NumberFormatException { - if (!s.endsWith("%")) { - throw new NumberFormatException(s + " doesn't end with '%'"); - } - - s = s.substring(0, s.length() - 1); - if (!s.matches(WEBVTT_PERCENTAGE_NUMBER_STRING)) { - throw new NumberFormatException(s + " contains an invalid character"); - } - - int value = Math.round(Float.parseFloat(s)); - if (value < 0 || value > 100) { - throw new NumberFormatException(value + " is out of range [0-100]"); - } - return value; - } - private static long parseTimestampUs(String s) throws NumberFormatException { - if (!s.matches(WEBVTT_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); + String[] parts = s.split("\\.", 2); + String[] subparts = parts[0].split(":"); + for (int i = 0; i < subparts.length; i++) { + value = value * 60 + Long.parseLong(subparts[i]); } return (value * 1000 + Long.parseLong(parts[1])) * 1000; } - private static Pair parseLinePositionAttributes(String s) { - String value; - Alignment alignment = null; - - int commaPos; - if ((commaPos = s.indexOf(",")) > 0 && commaPos < s.length() - 1) { - alignment = parseAlignment(s.substring(commaPos + 1)); - value = s.substring(0, commaPos); + private static void parseLineAttribute(String s, PositionHolder out) + throws NumberFormatException { + int lineAnchor; + int commaPosition = s.indexOf(","); + if (commaPosition != -1) { + lineAnchor = parsePositionAnchor(s.substring(commaPosition + 1)); + s = s.substring(0, commaPosition); } else { - value = s; + lineAnchor = Cue.TYPE_UNSET; } - - return new Pair(value, alignment); + float line; + int lineType; + if (s.endsWith("%")) { + line = parsePercentage(s); + lineType = Cue.LINE_TYPE_FRACTION; + } else { + line = Integer.parseInt(s); + lineType = Cue.LINE_TYPE_NUMBER; + } + out.position = line; + out.positionAnchor = lineAnchor; + out.lineType = lineType; } - private static Alignment parseAlignment(String s) { - Alignment alignment = null; - if ("start".equals(s)) { - alignment = Alignment.ALIGN_NORMAL; - } else if ("middle".equals(s)) { - alignment = Alignment.ALIGN_CENTER; - } else if ("end".equals(s)) { - alignment = Alignment.ALIGN_OPPOSITE; - } else if ("left".equals(s)) { - alignment = Alignment.ALIGN_NORMAL; - } else if ("right".equals(s)) { - alignment = Alignment.ALIGN_OPPOSITE; + private static void parsePositionAttribute(String s, PositionHolder out) + throws NumberFormatException { + int positionAnchor; + int commaPosition = s.indexOf(","); + if (commaPosition != -1) { + positionAnchor = parsePositionAnchor(s.substring(commaPosition + 1)); + s = s.substring(0, commaPosition); } else { - Log.w(TAG, "Invalid align value: " + s); + positionAnchor = Cue.TYPE_UNSET; } - return alignment; + out.position = parsePercentage(s); + out.positionAnchor = positionAnchor; + out.lineType = Cue.TYPE_UNSET; + } + + private static float parsePercentage(String s) throws NumberFormatException { + if (!s.endsWith("%")) { + throw new NumberFormatException("Percentages must end with %"); + } + s = s.substring(0, s.length() - 1); + return Float.parseFloat(s) / 100; + } + + private static int parsePositionAnchor(String s) { + switch (s) { + case "start": + return Cue.ANCHOR_TYPE_START; + case "middle": + return Cue.ANCHOR_TYPE_MIDDLE; + case "end": + return Cue.ANCHOR_TYPE_END; + default: + Log.w(TAG, "Invalid anchor value: " + s); + return Cue.TYPE_UNSET; + } + } + + private static Alignment parseTextAlignment(String s) { + switch (s) { + case "start": + case "left": + return Alignment.ALIGN_NORMAL; + case "middle": + return Alignment.ALIGN_CENTER; + case "end": + case "right": + return Alignment.ALIGN_OPPOSITE; + default: + Log.w(TAG, "Invalid alignment value: " + s); + return null; + } + } + + private static int alignmentToAnchor(Alignment alignment) { + if (alignment == null) { + return Cue.TYPE_UNSET; + } + switch (alignment) { + case ALIGN_NORMAL: + return Cue.ANCHOR_TYPE_START; + case ALIGN_CENTER: + return Cue.ANCHOR_TYPE_MIDDLE; + case ALIGN_OPPOSITE: + return Cue.ANCHOR_TYPE_END; + default: + Log.w(TAG, "Unrecognized alignment: " + alignment); + return Cue.ANCHOR_TYPE_START; + } + } + + private static final class PositionHolder { + + public float position; + public int positionAnchor; + public int lineType; + } }