summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text')
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java184
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java435
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java100
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java38
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java59
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java35
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java43
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java126
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java34
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java77
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java31
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java350
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java1014
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java62
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java1255
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java54
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java204
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java60
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java138
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java49
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java1059
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java54
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java259
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java51
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java446
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java83
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java301
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java71
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java259
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java72
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java756
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java399
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java69
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java151
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java268
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java81
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java241
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java63
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java19
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java347
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java101
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java56
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java329
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java319
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java550
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java125
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java119
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java115
-rw-r--r--mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java20
56 files changed, 11264 insertions, 0 deletions
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java
new file mode 100644
index 0000000000..c9acc1c8f5
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java
@@ -0,0 +1,184 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import android.annotation.TargetApi;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.view.accessibility.CaptioningManager;
+import android.view.accessibility.CaptioningManager.CaptionStyle;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * A compatibility wrapper for {@link CaptionStyle}.
+ */
+public final class CaptionStyleCompat {
+
+ /**
+ * The type of edge, which may be none. One of {@link #EDGE_TYPE_NONE}, {@link
+ * #EDGE_TYPE_OUTLINE}, {@link #EDGE_TYPE_DROP_SHADOW}, {@link #EDGE_TYPE_RAISED} or {@link
+ * #EDGE_TYPE_DEPRESSED}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ EDGE_TYPE_NONE,
+ EDGE_TYPE_OUTLINE,
+ EDGE_TYPE_DROP_SHADOW,
+ EDGE_TYPE_RAISED,
+ EDGE_TYPE_DEPRESSED
+ })
+ public @interface EdgeType {}
+ /**
+ * Edge type value specifying no character edges.
+ */
+ public static final int EDGE_TYPE_NONE = 0;
+ /**
+ * Edge type value specifying uniformly outlined character edges.
+ */
+ public static final int EDGE_TYPE_OUTLINE = 1;
+ /**
+ * Edge type value specifying drop-shadowed character edges.
+ */
+ public static final int EDGE_TYPE_DROP_SHADOW = 2;
+ /**
+ * Edge type value specifying raised bevel character edges.
+ */
+ public static final int EDGE_TYPE_RAISED = 3;
+ /**
+ * Edge type value specifying depressed bevel character edges.
+ */
+ public static final int EDGE_TYPE_DEPRESSED = 4;
+
+ /**
+ * Use color setting specified by the track and fallback to default caption style.
+ */
+ public static final int USE_TRACK_COLOR_SETTINGS = 1;
+
+ /** Default caption style. */
+ public static final CaptionStyleCompat DEFAULT =
+ new CaptionStyleCompat(
+ Color.WHITE,
+ Color.BLACK,
+ Color.TRANSPARENT,
+ EDGE_TYPE_NONE,
+ Color.WHITE,
+ /* typeface= */ null);
+
+ /**
+ * The preferred foreground color.
+ */
+ public final int foregroundColor;
+
+ /**
+ * The preferred background color.
+ */
+ public final int backgroundColor;
+
+ /**
+ * The preferred window color.
+ */
+ public final int windowColor;
+
+ /**
+ * The preferred edge type. One of:
+ * <ul>
+ * <li>{@link #EDGE_TYPE_NONE}
+ * <li>{@link #EDGE_TYPE_OUTLINE}
+ * <li>{@link #EDGE_TYPE_DROP_SHADOW}
+ * <li>{@link #EDGE_TYPE_RAISED}
+ * <li>{@link #EDGE_TYPE_DEPRESSED}
+ * </ul>
+ */
+ @EdgeType public final int edgeType;
+
+ /**
+ * The preferred edge color, if using an edge type other than {@link #EDGE_TYPE_NONE}.
+ */
+ public final int edgeColor;
+
+ /** The preferred typeface, or {@code null} if unspecified. */
+ @Nullable public final Typeface typeface;
+
+ /**
+ * Creates a {@link CaptionStyleCompat} equivalent to a provided {@link CaptionStyle}.
+ *
+ * @param captionStyle A {@link CaptionStyle}.
+ * @return The equivalent {@link CaptionStyleCompat}.
+ */
+ @TargetApi(19)
+ public static CaptionStyleCompat createFromCaptionStyle(
+ CaptioningManager.CaptionStyle captionStyle) {
+ if (Util.SDK_INT >= 21) {
+ return createFromCaptionStyleV21(captionStyle);
+ } else {
+ // Note - Any caller must be on at least API level 19 or greater (because CaptionStyle did
+ // not exist in earlier API levels).
+ return createFromCaptionStyleV19(captionStyle);
+ }
+ }
+
+ /**
+ * @param foregroundColor See {@link #foregroundColor}.
+ * @param backgroundColor See {@link #backgroundColor}.
+ * @param windowColor See {@link #windowColor}.
+ * @param edgeType See {@link #edgeType}.
+ * @param edgeColor See {@link #edgeColor}.
+ * @param typeface See {@link #typeface}.
+ */
+ public CaptionStyleCompat(
+ int foregroundColor,
+ int backgroundColor,
+ int windowColor,
+ @EdgeType int edgeType,
+ int edgeColor,
+ @Nullable Typeface typeface) {
+ this.foregroundColor = foregroundColor;
+ this.backgroundColor = backgroundColor;
+ this.windowColor = windowColor;
+ this.edgeType = edgeType;
+ this.edgeColor = edgeColor;
+ this.typeface = typeface;
+ }
+
+ @TargetApi(19)
+ @SuppressWarnings("ResourceType")
+ private static CaptionStyleCompat createFromCaptionStyleV19(
+ CaptioningManager.CaptionStyle captionStyle) {
+ return new CaptionStyleCompat(
+ captionStyle.foregroundColor, captionStyle.backgroundColor, Color.TRANSPARENT,
+ captionStyle.edgeType, captionStyle.edgeColor, captionStyle.getTypeface());
+ }
+
+ @TargetApi(21)
+ @SuppressWarnings("ResourceType")
+ private static CaptionStyleCompat createFromCaptionStyleV21(
+ CaptioningManager.CaptionStyle captionStyle) {
+ return new CaptionStyleCompat(
+ captionStyle.hasForegroundColor() ? captionStyle.foregroundColor : DEFAULT.foregroundColor,
+ captionStyle.hasBackgroundColor() ? captionStyle.backgroundColor : DEFAULT.backgroundColor,
+ captionStyle.hasWindowColor() ? captionStyle.windowColor : DEFAULT.windowColor,
+ captionStyle.hasEdgeType() ? captionStyle.edgeType : DEFAULT.edgeType,
+ captionStyle.hasEdgeColor() ? captionStyle.edgeColor : DEFAULT.edgeColor,
+ captionStyle.getTypeface());
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java
new file mode 100644
index 0000000000..71627781c1
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java
@@ -0,0 +1,435 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.text.Layout.Alignment;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Contains information about a specific cue, including textual content and formatting data.
+ */
+public class Cue {
+
+ /** The empty cue. */
+ public static final Cue EMPTY = new Cue("");
+
+ /** An unset position, width or size. */
+ // Note: We deliberately don't use Float.MIN_VALUE because it's positive & very close to zero.
+ public static final float DIMEN_UNSET = -Float.MAX_VALUE;
+
+ /**
+ * The type of anchor, which may be unset. One of {@link #TYPE_UNSET}, {@link #ANCHOR_TYPE_START},
+ * {@link #ANCHOR_TYPE_MIDDLE} or {@link #ANCHOR_TYPE_END}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_UNSET, ANCHOR_TYPE_START, ANCHOR_TYPE_MIDDLE, ANCHOR_TYPE_END})
+ public @interface AnchorType {}
+
+ /**
+ * 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;
+
+ /**
+ * The type of line, which may be unset. One of {@link #TYPE_UNSET}, {@link #LINE_TYPE_FRACTION}
+ * or {@link #LINE_TYPE_NUMBER}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({TYPE_UNSET, LINE_TYPE_FRACTION, LINE_TYPE_NUMBER})
+ public @interface LineType {}
+
+ /**
+ * 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 type of default text size for this cue, which may be unset. One of {@link #TYPE_UNSET},
+ * {@link #TEXT_SIZE_TYPE_FRACTIONAL}, {@link #TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING} or {@link
+ * #TEXT_SIZE_TYPE_ABSOLUTE}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ TYPE_UNSET,
+ TEXT_SIZE_TYPE_FRACTIONAL,
+ TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING,
+ TEXT_SIZE_TYPE_ABSOLUTE
+ })
+ public @interface TextSizeType {}
+
+ /** Text size is measured as a fraction of the viewport size minus the view padding. */
+ public static final int TEXT_SIZE_TYPE_FRACTIONAL = 0;
+
+ /** Text size is measured as a fraction of the viewport size, ignoring the view padding */
+ public static final int TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING = 1;
+
+ /** Text size is measured in number of pixels. */
+ public static final int TEXT_SIZE_TYPE_ABSOLUTE = 2;
+
+ /**
+ * The cue text, or null if this is an image cue. Note the {@link CharSequence} may be decorated
+ * with styling spans.
+ */
+ @Nullable public final CharSequence text;
+
+ /** The alignment of the cue text within the cue box, or null if the alignment is undefined. */
+ @Nullable public final Alignment textAlignment;
+
+ /** The cue image, or null if this is a text cue. */
+ @Nullable public final Bitmap bitmap;
+
+ /**
+ * 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}.
+ * <p>
+ * 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.
+ *
+ * <p>{@link #LINE_TYPE_FRACTION} indicates that {@link #line} is a fractional position within the
+ * viewport.
+ *
+ * <p>{@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, with 0 indicating zero offset
+ * from the start edge. When {@link #line} is negative lines count from the end of the viewport,
+ * with -1 indicating zero offset from the end edge. For horizontal text the line spacing is the
+ * height of the first line of the cue, and the start and end of the viewport are the top and
+ * bottom respectively.
+ *
+ * <p>Note that it's particularly important to consider the effect of {@link #lineAnchor} when
+ * using {@link #LINE_TYPE_NUMBER}. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_START)}
+ * positions a (potentially multi-line) cue at the very top of the viewport. {@code (line == -1 &&
+ * lineAnchor == ANCHOR_TYPE_END)} positions a (potentially multi-line) cue at the very bottom of
+ * the viewport. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_END)} and {@code (line == -1 &&
+ * lineAnchor == ANCHOR_TYPE_START)} position cues entirely outside of the viewport. {@code (line
+ * == 1 && lineAnchor == ANCHOR_TYPE_END)} positions a cue so that only the last line is visible
+ * at the top of the viewport. {@code (line == -2 && lineAnchor == ANCHOR_TYPE_START)} position a
+ * cue so that only its first line is visible at the bottom of the viewport.
+ */
+ public final @LineType 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}.
+ *
+ * <p>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 @AnchorType 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}.
+ * <p>
+ * 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}.
+ *
+ * <p>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 @AnchorType 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;
+
+ /**
+ * The bitmap height as a fraction of the of the viewport size, or {@link #DIMEN_UNSET} if the
+ * bitmap should be displayed at its natural height given the bitmap dimensions and the specified
+ * {@link #size}.
+ */
+ public final float bitmapHeight;
+
+ /**
+ * Specifies whether or not the {@link #windowColor} property is set.
+ */
+ public final boolean windowColorSet;
+
+ /**
+ * The fill color of the window.
+ */
+ public final int windowColor;
+
+ /**
+ * The default text size type for this cue's text, or {@link #TYPE_UNSET} if this cue has no
+ * default text size.
+ */
+ public final @TextSizeType int textSizeType;
+
+ /**
+ * The default text size for this cue's text, or {@link #DIMEN_UNSET} if this cue has no default
+ * text size.
+ */
+ public final float textSize;
+
+ /**
+ * Creates an image cue.
+ *
+ * @param bitmap See {@link #bitmap}.
+ * @param horizontalPosition The position of the horizontal anchor within the viewport, expressed
+ * as a fraction of the viewport width.
+ * @param horizontalPositionAnchor The horizontal anchor. One of {@link #ANCHOR_TYPE_START},
+ * {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}.
+ * @param verticalPosition The position of the vertical anchor within the viewport, expressed as a
+ * fraction of the viewport height.
+ * @param verticalPositionAnchor The vertical anchor. One of {@link #ANCHOR_TYPE_START}, {@link
+ * #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}.
+ * @param width The width of the cue as a fraction of the viewport width.
+ * @param height The height of the cue as a fraction of the viewport height, or {@link
+ * #DIMEN_UNSET} if the bitmap should be displayed at its natural height for the specified
+ * {@code width}.
+ */
+ public Cue(
+ Bitmap bitmap,
+ float horizontalPosition,
+ @AnchorType int horizontalPositionAnchor,
+ float verticalPosition,
+ @AnchorType int verticalPositionAnchor,
+ float width,
+ float height) {
+ this(
+ /* text= */ null,
+ /* textAlignment= */ null,
+ bitmap,
+ verticalPosition,
+ /* lineType= */ LINE_TYPE_FRACTION,
+ verticalPositionAnchor,
+ horizontalPosition,
+ horizontalPositionAnchor,
+ /* textSizeType= */ TYPE_UNSET,
+ /* textSize= */ DIMEN_UNSET,
+ width,
+ height,
+ /* windowColorSet= */ false,
+ /* windowColor= */ Color.BLACK);
+ }
+
+ /**
+ * Creates a text cue whose {@link #textAlignment} is null, whose type parameters are set to
+ * {@link #TYPE_UNSET} and whose dimension parameters are set to {@link #DIMEN_UNSET}.
+ *
+ * @param text See {@link #text}.
+ */
+ public Cue(CharSequence text) {
+ this(
+ text,
+ /* textAlignment= */ null,
+ /* line= */ DIMEN_UNSET,
+ /* lineType= */ TYPE_UNSET,
+ /* lineAnchor= */ TYPE_UNSET,
+ /* position= */ DIMEN_UNSET,
+ /* positionAnchor= */ TYPE_UNSET,
+ /* size= */ DIMEN_UNSET);
+ }
+
+ /**
+ * Creates a text cue.
+ *
+ * @param text See {@link #text}.
+ * @param textAlignment See {@link #textAlignment}.
+ * @param line See {@link #line}.
+ * @param lineType See {@link #lineType}.
+ * @param lineAnchor See {@link #lineAnchor}.
+ * @param position See {@link #position}.
+ * @param positionAnchor See {@link #positionAnchor}.
+ * @param size See {@link #size}.
+ */
+ public Cue(
+ CharSequence text,
+ @Nullable Alignment textAlignment,
+ float line,
+ @LineType int lineType,
+ @AnchorType int lineAnchor,
+ float position,
+ @AnchorType int positionAnchor,
+ float size) {
+ this(
+ text,
+ textAlignment,
+ line,
+ lineType,
+ lineAnchor,
+ position,
+ positionAnchor,
+ size,
+ /* windowColorSet= */ false,
+ /* windowColor= */ Color.BLACK);
+ }
+
+ /**
+ * Creates a text cue.
+ *
+ * @param text See {@link #text}.
+ * @param textAlignment See {@link #textAlignment}.
+ * @param line See {@link #line}.
+ * @param lineType See {@link #lineType}.
+ * @param lineAnchor See {@link #lineAnchor}.
+ * @param position See {@link #position}.
+ * @param positionAnchor See {@link #positionAnchor}.
+ * @param size See {@link #size}.
+ * @param textSizeType See {@link #textSizeType}.
+ * @param textSize See {@link #textSize}.
+ */
+ public Cue(
+ CharSequence text,
+ @Nullable Alignment textAlignment,
+ float line,
+ @LineType int lineType,
+ @AnchorType int lineAnchor,
+ float position,
+ @AnchorType int positionAnchor,
+ float size,
+ @TextSizeType int textSizeType,
+ float textSize) {
+ this(
+ text,
+ textAlignment,
+ /* bitmap= */ null,
+ line,
+ lineType,
+ lineAnchor,
+ position,
+ positionAnchor,
+ textSizeType,
+ textSize,
+ size,
+ /* bitmapHeight= */ DIMEN_UNSET,
+ /* windowColorSet= */ false,
+ /* windowColor= */ Color.BLACK);
+ }
+
+ /**
+ * Creates a text cue.
+ *
+ * @param text See {@link #text}.
+ * @param textAlignment See {@link #textAlignment}.
+ * @param line See {@link #line}.
+ * @param lineType See {@link #lineType}.
+ * @param lineAnchor See {@link #lineAnchor}.
+ * @param position See {@link #position}.
+ * @param positionAnchor See {@link #positionAnchor}.
+ * @param size See {@link #size}.
+ * @param windowColorSet See {@link #windowColorSet}.
+ * @param windowColor See {@link #windowColor}.
+ */
+ public Cue(
+ CharSequence text,
+ @Nullable Alignment textAlignment,
+ float line,
+ @LineType int lineType,
+ @AnchorType int lineAnchor,
+ float position,
+ @AnchorType int positionAnchor,
+ float size,
+ boolean windowColorSet,
+ int windowColor) {
+ this(
+ text,
+ textAlignment,
+ /* bitmap= */ null,
+ line,
+ lineType,
+ lineAnchor,
+ position,
+ positionAnchor,
+ /* textSizeType= */ TYPE_UNSET,
+ /* textSize= */ DIMEN_UNSET,
+ size,
+ /* bitmapHeight= */ DIMEN_UNSET,
+ windowColorSet,
+ windowColor);
+ }
+
+ private Cue(
+ @Nullable CharSequence text,
+ @Nullable Alignment textAlignment,
+ @Nullable Bitmap bitmap,
+ float line,
+ @LineType int lineType,
+ @AnchorType int lineAnchor,
+ float position,
+ @AnchorType int positionAnchor,
+ @TextSizeType int textSizeType,
+ float textSize,
+ float size,
+ float bitmapHeight,
+ boolean windowColorSet,
+ int windowColor) {
+ this.text = text;
+ this.textAlignment = textAlignment;
+ this.bitmap = bitmap;
+ this.line = line;
+ this.lineType = lineType;
+ this.lineAnchor = lineAnchor;
+ this.position = position;
+ this.positionAnchor = positionAnchor;
+ this.size = size;
+ this.bitmapHeight = bitmapHeight;
+ this.windowColorSet = windowColorSet;
+ this.windowColor = windowColor;
+ this.textSizeType = textSizeType;
+ this.textSize = textSize;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java
new file mode 100644
index 0000000000..b58bb1daea
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java
@@ -0,0 +1,100 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.SimpleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.nio.ByteBuffer;
+
+/**
+ * Base class for subtitle parsers that use their own decode thread.
+ */
+public abstract class SimpleSubtitleDecoder extends
+ SimpleDecoder<SubtitleInputBuffer, SubtitleOutputBuffer, SubtitleDecoderException> implements
+ SubtitleDecoder {
+
+ private final String name;
+
+ /** @param name The name of the decoder. */
+ @SuppressWarnings("initialization:method.invocation.invalid")
+ protected SimpleSubtitleDecoder(String name) {
+ super(new SubtitleInputBuffer[2], new SubtitleOutputBuffer[2]);
+ this.name = name;
+ setInitialInputBufferSize(1024);
+ }
+
+ @Override
+ public final String getName() {
+ return name;
+ }
+
+ @Override
+ public void setPositionUs(long timeUs) {
+ // Do nothing
+ }
+
+ @Override
+ protected final SubtitleInputBuffer createInputBuffer() {
+ return new SubtitleInputBuffer();
+ }
+
+ @Override
+ protected final SubtitleOutputBuffer createOutputBuffer() {
+ return new SimpleSubtitleOutputBuffer(this);
+ }
+
+ @Override
+ protected final SubtitleDecoderException createUnexpectedDecodeException(Throwable error) {
+ return new SubtitleDecoderException("Unexpected decode error", error);
+ }
+
+ @Override
+ protected final void releaseOutputBuffer(SubtitleOutputBuffer buffer) {
+ super.releaseOutputBuffer(buffer);
+ }
+
+ @SuppressWarnings("ByteBufferBackingArray")
+ @Override
+ @Nullable
+ protected final SubtitleDecoderException decode(
+ SubtitleInputBuffer inputBuffer, SubtitleOutputBuffer outputBuffer, boolean reset) {
+ try {
+ ByteBuffer inputData = Assertions.checkNotNull(inputBuffer.data);
+ Subtitle subtitle = decode(inputData.array(), inputData.limit(), reset);
+ outputBuffer.setContent(inputBuffer.timeUs, subtitle, inputBuffer.subsampleOffsetUs);
+ // Clear BUFFER_FLAG_DECODE_ONLY (see [Internal: b/27893809]).
+ outputBuffer.clearFlag(C.BUFFER_FLAG_DECODE_ONLY);
+ return null;
+ } catch (SubtitleDecoderException e) {
+ return e;
+ }
+ }
+
+ /**
+ * Decodes data into a {@link Subtitle}.
+ *
+ * @param data An array holding the data to be decoded, starting at position 0.
+ * @param size The size of the data to be decoded.
+ * @param reset Whether the decoder must be reset before decoding.
+ * @return The decoded {@link Subtitle}.
+ * @throws SubtitleDecoderException If a decoding error occurs.
+ */
+ protected abstract Subtitle decode(byte[] data, int size, boolean reset)
+ throws SubtitleDecoderException;
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java
new file mode 100644
index 0000000000..794b6c72f4
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java
@@ -0,0 +1,38 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+/**
+ * A {@link SubtitleOutputBuffer} for decoders that extend {@link SimpleSubtitleDecoder}.
+ */
+/* package */ final class SimpleSubtitleOutputBuffer extends SubtitleOutputBuffer {
+
+ private final SimpleSubtitleDecoder owner;
+
+ /**
+ * @param owner The decoder that owns this buffer.
+ */
+ public SimpleSubtitleOutputBuffer(SimpleSubtitleDecoder owner) {
+ super();
+ this.owner = owner;
+ }
+
+ @Override
+ public final void release() {
+ owner.releaseOutputBuffer(this);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java
new file mode 100644
index 0000000000..0c2a259f37
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java
@@ -0,0 +1,59 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import java.util.List;
+
+/**
+ * A subtitle consisting of timed {@link Cue}s.
+ */
+public interface Subtitle {
+
+ /**
+ * Returns the index of the first event that occurs after a given time (exclusive).
+ *
+ * @param timeUs The time in microseconds.
+ * @return The index of the next event, or {@link C#INDEX_UNSET} if there are no events after the
+ * specified time.
+ */
+ int getNextEventTimeIndex(long timeUs);
+
+ /**
+ * Returns the number of event times, where events are defined as points in time at which the cues
+ * returned by {@link #getCues(long)} changes.
+ *
+ * @return The number of event times.
+ */
+ int getEventTimeCount();
+
+ /**
+ * Returns the event time at a specified index.
+ *
+ * @param index The index of the event time to obtain.
+ * @return The event time in microseconds.
+ */
+ long getEventTime(int index);
+
+ /**
+ * Retrieve the cues that should be displayed at a given time.
+ *
+ * @param timeUs The time in microseconds.
+ * @return A list of cues that should be displayed, possibly empty.
+ */
+ List<Cue> getCues(long timeUs);
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java
new file mode 100644
index 0000000000..dcf1a0c254
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java
@@ -0,0 +1,35 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.Decoder;
+
+/**
+ * Decodes {@link Subtitle}s from {@link SubtitleInputBuffer}s.
+ */
+public interface SubtitleDecoder extends
+ Decoder<SubtitleInputBuffer, SubtitleOutputBuffer, SubtitleDecoderException> {
+
+ /**
+ * Informs the decoder of the current playback position.
+ * <p>
+ * Must be called prior to each attempt to dequeue output buffers from the decoder.
+ *
+ * @param positionUs The current playback position in microseconds.
+ */
+ void setPositionUs(long positionUs);
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java
new file mode 100644
index 0000000000..9ee15188b0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java
@@ -0,0 +1,43 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+/**
+ * Thrown when an error occurs decoding subtitle data.
+ */
+public class SubtitleDecoderException extends Exception {
+
+ /**
+ * @param message The detail message for this exception.
+ */
+ public SubtitleDecoderException(String message) {
+ super(message);
+ }
+
+ /** @param cause The cause of this exception. */
+ public SubtitleDecoderException(Exception cause) {
+ super(cause);
+ }
+
+ /**
+ * @param message The detail message for this exception.
+ * @param cause The cause of this exception.
+ */
+ public SubtitleDecoderException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java
new file mode 100644
index 0000000000..2fb0200f0d
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java
@@ -0,0 +1,126 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.Cea608Decoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.Cea708Decoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb.DvbDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs.PgsDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip.SubripDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml.TtmlDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g.Tx3gDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.Mp4WebvttDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.WebvttDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+
+/**
+ * A factory for {@link SubtitleDecoder} instances.
+ */
+public interface SubtitleDecoderFactory {
+
+ /**
+ * Returns whether the factory is able to instantiate a {@link SubtitleDecoder} for the given
+ * {@link Format}.
+ *
+ * @param format The {@link Format}.
+ * @return Whether the factory can instantiate a suitable {@link SubtitleDecoder}.
+ */
+ boolean supportsFormat(Format format);
+
+ /**
+ * Creates a {@link SubtitleDecoder} for the given {@link Format}.
+ *
+ * @param format The {@link Format}.
+ * @return A new {@link SubtitleDecoder}.
+ * @throws IllegalArgumentException If the {@link Format} is not supported.
+ */
+ SubtitleDecoder createDecoder(Format format);
+
+ /**
+ * Default {@link SubtitleDecoderFactory} implementation.
+ *
+ * <p>The formats supported by this factory are:
+ *
+ * <ul>
+ * <li>WebVTT ({@link WebvttDecoder})
+ * <li>WebVTT (MP4) ({@link Mp4WebvttDecoder})
+ * <li>TTML ({@link TtmlDecoder})
+ * <li>SubRip ({@link SubripDecoder})
+ * <li>SSA/ASS ({@link SsaDecoder})
+ * <li>TX3G ({@link Tx3gDecoder})
+ * <li>Cea608 ({@link Cea608Decoder})
+ * <li>Cea708 ({@link Cea708Decoder})
+ * <li>DVB ({@link DvbDecoder})
+ * <li>PGS ({@link PgsDecoder})
+ * </ul>
+ */
+ SubtitleDecoderFactory DEFAULT =
+ new SubtitleDecoderFactory() {
+
+ @Override
+ public boolean supportsFormat(Format format) {
+ @Nullable String mimeType = format.sampleMimeType;
+ return MimeTypes.TEXT_VTT.equals(mimeType)
+ || MimeTypes.TEXT_SSA.equals(mimeType)
+ || MimeTypes.APPLICATION_TTML.equals(mimeType)
+ || MimeTypes.APPLICATION_MP4VTT.equals(mimeType)
+ || MimeTypes.APPLICATION_SUBRIP.equals(mimeType)
+ || MimeTypes.APPLICATION_TX3G.equals(mimeType)
+ || MimeTypes.APPLICATION_CEA608.equals(mimeType)
+ || MimeTypes.APPLICATION_MP4CEA608.equals(mimeType)
+ || MimeTypes.APPLICATION_CEA708.equals(mimeType)
+ || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)
+ || MimeTypes.APPLICATION_PGS.equals(mimeType);
+ }
+
+ @Override
+ public SubtitleDecoder createDecoder(Format format) {
+ @Nullable String mimeType = format.sampleMimeType;
+ if (mimeType != null) {
+ switch (mimeType) {
+ case MimeTypes.TEXT_VTT:
+ return new WebvttDecoder();
+ case MimeTypes.TEXT_SSA:
+ return new SsaDecoder(format.initializationData);
+ case MimeTypes.APPLICATION_MP4VTT:
+ return new Mp4WebvttDecoder();
+ case MimeTypes.APPLICATION_TTML:
+ return new TtmlDecoder();
+ case MimeTypes.APPLICATION_SUBRIP:
+ return new SubripDecoder();
+ case MimeTypes.APPLICATION_TX3G:
+ return new Tx3gDecoder(format.initializationData);
+ case MimeTypes.APPLICATION_CEA608:
+ case MimeTypes.APPLICATION_MP4CEA608:
+ return new Cea608Decoder(mimeType, format.accessibilityChannel);
+ case MimeTypes.APPLICATION_CEA708:
+ return new Cea708Decoder(format.accessibilityChannel, format.initializationData);
+ case MimeTypes.APPLICATION_DVBSUBS:
+ return new DvbDecoder(format.initializationData);
+ case MimeTypes.APPLICATION_PGS:
+ return new PgsDecoder();
+ default:
+ break;
+ }
+ }
+ throw new IllegalArgumentException(
+ "Attempted to create decoder for unsupported MIME type: " + mimeType);
+ }
+ };
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java
new file mode 100644
index 0000000000..dbcfe649b8
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java
@@ -0,0 +1,34 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer;
+
+/** A {@link DecoderInputBuffer} for a {@link SubtitleDecoder}. */
+public class SubtitleInputBuffer extends DecoderInputBuffer {
+
+ /**
+ * An offset that must be added to the subtitle's event times after it's been decoded, or
+ * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@link #timeUs} should be added.
+ */
+ public long subsampleOffsetUs;
+
+ public SubtitleInputBuffer() {
+ super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL);
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java
new file mode 100644
index 0000000000..9cc7671b24
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java
@@ -0,0 +1,77 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.OutputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.List;
+
+/**
+ * Base class for {@link SubtitleDecoder} output buffers.
+ */
+public abstract class SubtitleOutputBuffer extends OutputBuffer implements Subtitle {
+
+ @Nullable private Subtitle subtitle;
+ private long subsampleOffsetUs;
+
+ /**
+ * Sets the content of the output buffer, consisting of a {@link Subtitle} and associated
+ * metadata.
+ *
+ * @param timeUs The time of the start of the subtitle in microseconds.
+ * @param subtitle The subtitle.
+ * @param subsampleOffsetUs An offset that must be added to the subtitle's event times, or
+ * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@code timeUs} should be added.
+ */
+ public void setContent(long timeUs, Subtitle subtitle, long subsampleOffsetUs) {
+ this.timeUs = timeUs;
+ this.subtitle = subtitle;
+ this.subsampleOffsetUs = subsampleOffsetUs == Format.OFFSET_SAMPLE_RELATIVE ? this.timeUs
+ : subsampleOffsetUs;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return Assertions.checkNotNull(subtitle).getEventTimeCount();
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ return Assertions.checkNotNull(subtitle).getEventTime(index) + subsampleOffsetUs;
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return Assertions.checkNotNull(subtitle).getNextEventTimeIndex(timeUs - subsampleOffsetUs);
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return Assertions.checkNotNull(subtitle).getCues(timeUs - subsampleOffsetUs);
+ }
+
+ @Override
+ public abstract void release();
+
+ @Override
+ public void clear() {
+ super.clear();
+ subtitle = null;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java
new file mode 100644
index 0000000000..b15a2f1b35
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import java.util.List;
+
+/**
+ * Receives text output.
+ */
+public interface TextOutput {
+
+ /**
+ * Called when there is a change in the {@link Cue}s.
+ *
+ * @param cues The {@link Cue}s. May be empty.
+ */
+ void onCues(List<Cue> cues);
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java
new file mode 100644
index 0000000000..428b106fcd
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java
@@ -0,0 +1,350 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import android.os.Handler;
+import android.os.Handler.Callback;
+import android.os.Looper;
+import android.os.Message;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.BaseRenderer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A renderer for text.
+ * <p>
+ * {@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances obtained
+ * from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s is
+ * delegated to a {@link TextOutput}.
+ */
+public final class TextRenderer extends BaseRenderer implements Callback {
+
+ private static final String TAG = "TextRenderer";
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({
+ REPLACEMENT_STATE_NONE,
+ REPLACEMENT_STATE_SIGNAL_END_OF_STREAM,
+ REPLACEMENT_STATE_WAIT_END_OF_STREAM
+ })
+ private @interface ReplacementState {}
+ /**
+ * The decoder does not need to be replaced.
+ */
+ private static final int REPLACEMENT_STATE_NONE = 0;
+ /**
+ * The decoder needs to be replaced, but we haven't yet signaled an end of stream to the existing
+ * decoder. We need to do so in order to ensure that it outputs any remaining buffers before we
+ * release it.
+ */
+ private static final int REPLACEMENT_STATE_SIGNAL_END_OF_STREAM = 1;
+ /**
+ * The decoder needs to be replaced, and we've signaled an end of stream to the existing decoder.
+ * We're waiting for the decoder to output an end of stream signal to indicate that it has output
+ * any remaining buffers before we release it.
+ */
+ private static final int REPLACEMENT_STATE_WAIT_END_OF_STREAM = 2;
+
+ private static final int MSG_UPDATE_OUTPUT = 0;
+
+ @Nullable private final Handler outputHandler;
+ private final TextOutput output;
+ private final SubtitleDecoderFactory decoderFactory;
+ private final FormatHolder formatHolder;
+
+ private boolean inputStreamEnded;
+ private boolean outputStreamEnded;
+ @ReplacementState private int decoderReplacementState;
+ @Nullable private Format streamFormat;
+ @Nullable private SubtitleDecoder decoder;
+ @Nullable private SubtitleInputBuffer nextInputBuffer;
+ @Nullable private SubtitleOutputBuffer subtitle;
+ @Nullable private SubtitleOutputBuffer nextSubtitle;
+ private int nextSubtitleEventIndex;
+
+ /**
+ * @param output The output.
+ * @param outputLooper The looper associated with the thread on which the output should be called.
+ * If the output makes use of standard Android UI components, then this should normally be the
+ * looper associated with the application's main thread, which can be obtained using {@link
+ * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called
+ * directly on the player's internal rendering thread.
+ */
+ public TextRenderer(TextOutput output, @Nullable Looper outputLooper) {
+ this(output, outputLooper, SubtitleDecoderFactory.DEFAULT);
+ }
+
+ /**
+ * @param output The output.
+ * @param outputLooper The looper associated with the thread on which the output should be called.
+ * If the output makes use of standard Android UI components, then this should normally be the
+ * looper associated with the application's main thread, which can be obtained using {@link
+ * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called
+ * directly on the player's internal rendering thread.
+ * @param decoderFactory A factory from which to obtain {@link SubtitleDecoder} instances.
+ */
+ public TextRenderer(
+ TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) {
+ super(C.TRACK_TYPE_TEXT);
+ this.output = Assertions.checkNotNull(output);
+ this.outputHandler =
+ outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this);
+ this.decoderFactory = decoderFactory;
+ formatHolder = new FormatHolder();
+ }
+
+ @Override
+ @Capabilities
+ public int supportsFormat(Format format) {
+ if (decoderFactory.supportsFormat(format)) {
+ return RendererCapabilities.create(
+ supportsFormatDrm(null, format.drmInitData) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM);
+ } else if (MimeTypes.isText(format.sampleMimeType)) {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE);
+ } else {
+ return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE);
+ }
+ }
+
+ @Override
+ protected void onStreamChanged(Format[] formats, long offsetUs) {
+ streamFormat = formats[0];
+ if (decoder != null) {
+ decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM;
+ } else {
+ decoder = decoderFactory.createDecoder(streamFormat);
+ }
+ }
+
+ @Override
+ protected void onPositionReset(long positionUs, boolean joining) {
+ inputStreamEnded = false;
+ outputStreamEnded = false;
+ resetOutputAndDecoder();
+ }
+
+ @Override
+ public void render(long positionUs, long elapsedRealtimeUs) {
+ if (outputStreamEnded) {
+ return;
+ }
+
+ if (nextSubtitle == null) {
+ decoder.setPositionUs(positionUs);
+ try {
+ nextSubtitle = decoder.dequeueOutputBuffer();
+ } catch (SubtitleDecoderException e) {
+ handleDecoderError(e);
+ return;
+ }
+ }
+
+ if (getState() != STATE_STARTED) {
+ return;
+ }
+
+ boolean textRendererNeedsUpdate = false;
+ if (subtitle != null) {
+ // We're iterating through the events in a subtitle. Set textRendererNeedsUpdate if we
+ // advance to the next event.
+ long subtitleNextEventTimeUs = getNextEventTime();
+ while (subtitleNextEventTimeUs <= positionUs) {
+ nextSubtitleEventIndex++;
+ subtitleNextEventTimeUs = getNextEventTime();
+ textRendererNeedsUpdate = true;
+ }
+ }
+
+ if (nextSubtitle != null) {
+ if (nextSubtitle.isEndOfStream()) {
+ if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) {
+ if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) {
+ replaceDecoder();
+ } else {
+ releaseBuffers();
+ outputStreamEnded = true;
+ }
+ }
+ } else if (nextSubtitle.timeUs <= positionUs) {
+ // Advance to the next subtitle. Sync the next event index and trigger an update.
+ if (subtitle != null) {
+ subtitle.release();
+ }
+ subtitle = nextSubtitle;
+ nextSubtitle = null;
+ nextSubtitleEventIndex = subtitle.getNextEventTimeIndex(positionUs);
+ textRendererNeedsUpdate = true;
+ }
+ }
+
+ if (textRendererNeedsUpdate) {
+ // textRendererNeedsUpdate is set and we're playing. Update the renderer.
+ updateOutput(subtitle.getCues(positionUs));
+ }
+
+ if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) {
+ return;
+ }
+
+ try {
+ while (!inputStreamEnded) {
+ if (nextInputBuffer == null) {
+ nextInputBuffer = decoder.dequeueInputBuffer();
+ if (nextInputBuffer == null) {
+ return;
+ }
+ }
+ if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) {
+ nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM);
+ decoder.queueInputBuffer(nextInputBuffer);
+ nextInputBuffer = null;
+ decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM;
+ return;
+ }
+ // Try and read the next subtitle from the source.
+ int result = readSource(formatHolder, nextInputBuffer, false);
+ if (result == C.RESULT_BUFFER_READ) {
+ if (nextInputBuffer.isEndOfStream()) {
+ inputStreamEnded = true;
+ } else {
+ nextInputBuffer.subsampleOffsetUs = formatHolder.format.subsampleOffsetUs;
+ nextInputBuffer.flip();
+ }
+ decoder.queueInputBuffer(nextInputBuffer);
+ nextInputBuffer = null;
+ } else if (result == C.RESULT_NOTHING_READ) {
+ return;
+ }
+ }
+ } catch (SubtitleDecoderException e) {
+ handleDecoderError(e);
+ return;
+ }
+ }
+
+ @Override
+ protected void onDisabled() {
+ streamFormat = null;
+ clearOutput();
+ releaseDecoder();
+ }
+
+ @Override
+ public boolean isEnded() {
+ return outputStreamEnded;
+ }
+
+ @Override
+ public boolean isReady() {
+ // Don't block playback whilst subtitles are loading.
+ // Note: To change this behavior, it will be necessary to consider [Internal: b/12949941].
+ return true;
+ }
+
+ private void releaseBuffers() {
+ nextInputBuffer = null;
+ nextSubtitleEventIndex = C.INDEX_UNSET;
+ if (subtitle != null) {
+ subtitle.release();
+ subtitle = null;
+ }
+ if (nextSubtitle != null) {
+ nextSubtitle.release();
+ nextSubtitle = null;
+ }
+ }
+
+ private void releaseDecoder() {
+ releaseBuffers();
+ decoder.release();
+ decoder = null;
+ decoderReplacementState = REPLACEMENT_STATE_NONE;
+ }
+
+ private void replaceDecoder() {
+ releaseDecoder();
+ decoder = decoderFactory.createDecoder(streamFormat);
+ }
+
+ private long getNextEventTime() {
+ return nextSubtitleEventIndex == C.INDEX_UNSET
+ || nextSubtitleEventIndex >= subtitle.getEventTimeCount()
+ ? Long.MAX_VALUE : subtitle.getEventTime(nextSubtitleEventIndex);
+ }
+
+ private void updateOutput(List<Cue> cues) {
+ if (outputHandler != null) {
+ outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cues).sendToTarget();
+ } else {
+ invokeUpdateOutputInternal(cues);
+ }
+ }
+
+ private void clearOutput() {
+ updateOutput(Collections.emptyList());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public boolean handleMessage(Message msg) {
+ switch (msg.what) {
+ case MSG_UPDATE_OUTPUT:
+ invokeUpdateOutputInternal((List<Cue>) msg.obj);
+ return true;
+ default:
+ throw new IllegalStateException();
+ }
+ }
+
+ private void invokeUpdateOutputInternal(List<Cue> cues) {
+ output.onCues(cues);
+ }
+
+ /**
+ * Called when {@link #decoder} throws an exception, so it can be logged and playback can
+ * continue.
+ *
+ * <p>Logs {@code e} and resets state to allow decoding the next sample.
+ */
+ private void handleDecoderError(SubtitleDecoderException e) {
+ Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e);
+ resetOutputAndDecoder();
+ }
+
+ private void resetOutputAndDecoder() {
+ clearOutput();
+ if (decoderReplacementState != REPLACEMENT_STATE_NONE) {
+ replaceDecoder();
+ } else {
+ releaseBuffers();
+ decoder.flush();
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java
new file mode 100644
index 0000000000..320b4f3f07
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java
@@ -0,0 +1,1014 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.UnderlineSpan;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608").
+ */
+public final class Cea608Decoder extends CeaDecoder {
+
+ private static final String TAG = "Cea608Decoder";
+
+ private static final int CC_VALID_FLAG = 0x04;
+ private static final int CC_TYPE_FLAG = 0x02;
+ private static final int CC_FIELD_FLAG = 0x01;
+
+ private static final int NTSC_CC_FIELD_1 = 0x00;
+ private static final int NTSC_CC_FIELD_2 = 0x01;
+ private static final int NTSC_CC_CHANNEL_1 = 0x00;
+ private static final int NTSC_CC_CHANNEL_2 = 0x01;
+
+ private static final int CC_MODE_UNKNOWN = 0;
+ private static final int CC_MODE_ROLL_UP = 1;
+ private static final int CC_MODE_POP_ON = 2;
+ private static final int CC_MODE_PAINT_ON = 3;
+
+ private static final int[] ROW_INDICES = new int[] {11, 1, 3, 12, 14, 5, 7, 9};
+ private static final int[] COLUMN_INDICES = new int[] {0, 4, 8, 12, 16, 20, 24, 28};
+
+ private static final int[] STYLE_COLORS =
+ new int[] {
+ Color.WHITE, Color.GREEN, Color.BLUE, Color.CYAN, Color.RED, Color.YELLOW, Color.MAGENTA
+ };
+ private static final int STYLE_ITALICS = 0x07;
+ private static final int STYLE_UNCHANGED = 0x08;
+
+ // The default number of rows to display in roll-up captions mode.
+ private static final int DEFAULT_CAPTIONS_ROW_COUNT = 4;
+
+ // An implied first byte for packets that are only 2 bytes long, consisting of marker bits
+ // (0b11111) + valid bit (0b1) + NTSC field 1 type bits (0b00).
+ private static final byte CC_IMPLICIT_DATA_HEADER = (byte) 0xFC;
+
+ /**
+ * Command initiating pop-on style captioning. Subsequent data should be loaded into a
+ * non-displayed memory and held there until the {@link #CTRL_END_OF_CAPTION} command is received,
+ * at which point the non-displayed memory becomes the displayed memory (and vice versa).
+ */
+ private static final byte CTRL_RESUME_CAPTION_LOADING = 0x20;
+
+ private static final byte CTRL_BACKSPACE = 0x21;
+
+ private static final byte CTRL_DELETE_TO_END_OF_ROW = 0x24;
+
+ /**
+ * Command initiating roll-up style captioning, with the maximum of 2 rows displayed
+ * simultaneously.
+ */
+ private static final byte CTRL_ROLL_UP_CAPTIONS_2_ROWS = 0x25;
+ /**
+ * Command initiating roll-up style captioning, with the maximum of 3 rows displayed
+ * simultaneously.
+ */
+ private static final byte CTRL_ROLL_UP_CAPTIONS_3_ROWS = 0x26;
+ /**
+ * Command initiating roll-up style captioning, with the maximum of 4 rows displayed
+ * simultaneously.
+ */
+ private static final byte CTRL_ROLL_UP_CAPTIONS_4_ROWS = 0x27;
+
+ /**
+ * Command initiating paint-on style captioning. Subsequent data should be addressed immediately
+ * to displayed memory without need for the {@link #CTRL_RESUME_CAPTION_LOADING} command.
+ */
+ private static final byte CTRL_RESUME_DIRECT_CAPTIONING = 0x29;
+ /**
+ * TEXT commands are switching to TEXT service. All consecutive incoming data must be filtered out
+ * until a command is received that switches back to the CAPTION service.
+ */
+ private static final byte CTRL_TEXT_RESTART = 0x2A;
+
+ private static final byte CTRL_RESUME_TEXT_DISPLAY = 0x2B;
+
+ private static final byte CTRL_ERASE_DISPLAYED_MEMORY = 0x2C;
+ private static final byte CTRL_CARRIAGE_RETURN = 0x2D;
+ private static final byte CTRL_ERASE_NON_DISPLAYED_MEMORY = 0x2E;
+
+ /**
+ * Command indicating the end of a pop-on style caption. At this point the caption loaded in
+ * non-displayed memory should be swapped with the one in displayed memory. If no {@link
+ * #CTRL_RESUME_CAPTION_LOADING} command has been received, this command forces the receiver into
+ * pop-on style.
+ */
+ private static final byte CTRL_END_OF_CAPTION = 0x2F;
+
+ // Basic North American 608 CC char set, mostly ASCII. Indexed by (char-0x20).
+ private static final int[] BASIC_CHARACTER_SET = new int[] {
+ 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, // ! " # $ % & '
+ 0x28, 0x29, // ( )
+ 0xE1, // 2A: 225 'á' "Latin small letter A with acute"
+ 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, // + , - . /
+ 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, // 0 1 2 3 4 5 6 7
+ 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, // 8 9 : ; < = > ?
+ 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, // @ A B C D E F G
+ 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, // H I J K L M N O
+ 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, // P Q R S T U V W
+ 0x58, 0x59, 0x5A, 0x5B, // X Y Z [
+ 0xE9, // 5C: 233 'é' "Latin small letter E with acute"
+ 0x5D, // ]
+ 0xED, // 5E: 237 'í' "Latin small letter I with acute"
+ 0xF3, // 5F: 243 'ó' "Latin small letter O with acute"
+ 0xFA, // 60: 250 'ú' "Latin small letter U with acute"
+ 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, // a b c d e f g
+ 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, // h i j k l m n o
+ 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, // p q r s t u v w
+ 0x78, 0x79, 0x7A, // x y z
+ 0xE7, // 7B: 231 'ç' "Latin small letter C with cedilla"
+ 0xF7, // 7C: 247 '÷' "Division sign"
+ 0xD1, // 7D: 209 'Ñ' "Latin capital letter N with tilde"
+ 0xF1, // 7E: 241 'ñ' "Latin small letter N with tilde"
+ 0x25A0 // 7F: "Black Square" (NB: 2588 = Full Block)
+ };
+
+ // Special North American 608 CC char set.
+ private static final int[] SPECIAL_CHARACTER_SET = new int[] {
+ 0xAE, // 30: 174 '®' "Registered Sign" - registered trademark symbol
+ 0xB0, // 31: 176 '°' "Degree Sign"
+ 0xBD, // 32: 189 '½' "Vulgar Fraction One Half" (1/2 symbol)
+ 0xBF, // 33: 191 '¿' "Inverted Question Mark"
+ 0x2122, // 34: "Trade Mark Sign" (tm superscript)
+ 0xA2, // 35: 162 '¢' "Cent Sign"
+ 0xA3, // 36: 163 '£' "Pound Sign" - pounds sterling
+ 0x266A, // 37: "Eighth Note" - music note
+ 0xE0, // 38: 224 'à' "Latin small letter A with grave"
+ 0x20, // 39: TRANSPARENT SPACE - for now use ordinary space
+ 0xE8, // 3A: 232 'è' "Latin small letter E with grave"
+ 0xE2, // 3B: 226 'â' "Latin small letter A with circumflex"
+ 0xEA, // 3C: 234 'ê' "Latin small letter E with circumflex"
+ 0xEE, // 3D: 238 'î' "Latin small letter I with circumflex"
+ 0xF4, // 3E: 244 'ô' "Latin small letter O with circumflex"
+ 0xFB // 3F: 251 'û' "Latin small letter U with circumflex"
+ };
+
+ // Extended Spanish/Miscellaneous and French char set.
+ private static final int[] SPECIAL_ES_FR_CHARACTER_SET = new int[] {
+ // Spanish and misc.
+ 0xC1, 0xC9, 0xD3, 0xDA, 0xDC, 0xFC, 0x2018, 0xA1,
+ 0x2A, 0x27, 0x2014, 0xA9, 0x2120, 0x2022, 0x201C, 0x201D,
+ // French.
+ 0xC0, 0xC2, 0xC7, 0xC8, 0xCA, 0xCB, 0xEB, 0xCE,
+ 0xCF, 0xEF, 0xD4, 0xD9, 0xF9, 0xDB, 0xAB, 0xBB
+ };
+
+ //Extended Portuguese and German/Danish char set.
+ private static final int[] SPECIAL_PT_DE_CHARACTER_SET = new int[] {
+ // Portuguese.
+ 0xC3, 0xE3, 0xCD, 0xCC, 0xEC, 0xD2, 0xF2, 0xD5,
+ 0xF5, 0x7B, 0x7D, 0x5C, 0x5E, 0x5F, 0x7C, 0x7E,
+ // German/Danish.
+ 0xC4, 0xE4, 0xD6, 0xF6, 0xDF, 0xA5, 0xA4, 0x2502,
+ 0xC5, 0xE5, 0xD8, 0xF8, 0x250C, 0x2510, 0x2514, 0x2518
+ };
+
+ private static final boolean[] ODD_PARITY_BYTE_TABLE = {
+ false, true, true, false, true, false, false, true, // 0
+ true, false, false, true, false, true, true, false, // 8
+ true, false, false, true, false, true, true, false, // 16
+ false, true, true, false, true, false, false, true, // 24
+ true, false, false, true, false, true, true, false, // 32
+ false, true, true, false, true, false, false, true, // 40
+ false, true, true, false, true, false, false, true, // 48
+ true, false, false, true, false, true, true, false, // 56
+ true, false, false, true, false, true, true, false, // 64
+ false, true, true, false, true, false, false, true, // 72
+ false, true, true, false, true, false, false, true, // 80
+ true, false, false, true, false, true, true, false, // 88
+ false, true, true, false, true, false, false, true, // 96
+ true, false, false, true, false, true, true, false, // 104
+ true, false, false, true, false, true, true, false, // 112
+ false, true, true, false, true, false, false, true, // 120
+ true, false, false, true, false, true, true, false, // 128
+ false, true, true, false, true, false, false, true, // 136
+ false, true, true, false, true, false, false, true, // 144
+ true, false, false, true, false, true, true, false, // 152
+ false, true, true, false, true, false, false, true, // 160
+ true, false, false, true, false, true, true, false, // 168
+ true, false, false, true, false, true, true, false, // 176
+ false, true, true, false, true, false, false, true, // 184
+ false, true, true, false, true, false, false, true, // 192
+ true, false, false, true, false, true, true, false, // 200
+ true, false, false, true, false, true, true, false, // 208
+ false, true, true, false, true, false, false, true, // 216
+ true, false, false, true, false, true, true, false, // 224
+ false, true, true, false, true, false, false, true, // 232
+ false, true, true, false, true, false, false, true, // 240
+ true, false, false, true, false, true, true, false, // 248
+ };
+
+ private final ParsableByteArray ccData;
+ private final int packetLength;
+ private final int selectedField;
+ private final int selectedChannel;
+ private final ArrayList<CueBuilder> cueBuilders;
+
+ private CueBuilder currentCueBuilder;
+ private List<Cue> cues;
+ private List<Cue> lastCues;
+
+ private int captionMode;
+ private int captionRowCount;
+
+ private boolean isCaptionValid;
+ private boolean repeatableControlSet;
+ private byte repeatableControlCc1;
+ private byte repeatableControlCc2;
+ private int currentChannel;
+
+ // The incoming characters may belong to 3 different services based on the last received control
+ // codes. The 3 services are Captioning, Text and XDS. The decoder only processes Captioning
+ // service bytes and drops the rest.
+ private boolean isInCaptionService;
+
+ public Cea608Decoder(String mimeType, int accessibilityChannel) {
+ ccData = new ParsableByteArray();
+ cueBuilders = new ArrayList<>();
+ currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT);
+ currentChannel = NTSC_CC_CHANNEL_1;
+ packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3;
+ switch (accessibilityChannel) {
+ case 1:
+ selectedChannel = NTSC_CC_CHANNEL_1;
+ selectedField = NTSC_CC_FIELD_1;
+ break;
+ case 2:
+ selectedChannel = NTSC_CC_CHANNEL_2;
+ selectedField = NTSC_CC_FIELD_1;
+ break;
+ case 3:
+ selectedChannel = NTSC_CC_CHANNEL_1;
+ selectedField = NTSC_CC_FIELD_2;
+ break;
+ case 4:
+ selectedChannel = NTSC_CC_CHANNEL_2;
+ selectedField = NTSC_CC_FIELD_2;
+ break;
+ default:
+ Log.w(TAG, "Invalid channel. Defaulting to CC1.");
+ selectedChannel = NTSC_CC_CHANNEL_1;
+ selectedField = NTSC_CC_FIELD_1;
+ }
+
+ setCaptionMode(CC_MODE_UNKNOWN);
+ resetCueBuilders();
+ isInCaptionService = true;
+ }
+
+ @Override
+ public String getName() {
+ return "Cea608Decoder";
+ }
+
+ @Override
+ public void flush() {
+ super.flush();
+ cues = null;
+ lastCues = null;
+ setCaptionMode(CC_MODE_UNKNOWN);
+ setCaptionRowCount(DEFAULT_CAPTIONS_ROW_COUNT);
+ resetCueBuilders();
+ isCaptionValid = false;
+ repeatableControlSet = false;
+ repeatableControlCc1 = 0;
+ repeatableControlCc2 = 0;
+ currentChannel = NTSC_CC_CHANNEL_1;
+ isInCaptionService = true;
+ }
+
+ @Override
+ public void release() {
+ // Do nothing
+ }
+
+ @Override
+ protected boolean isNewSubtitleDataAvailable() {
+ return cues != lastCues;
+ }
+
+ @Override
+ protected Subtitle createSubtitle() {
+ lastCues = cues;
+ return new CeaSubtitle(cues);
+ }
+
+ @SuppressWarnings("ByteBufferBackingArray")
+ @Override
+ protected void decode(SubtitleInputBuffer inputBuffer) {
+ ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit());
+ boolean captionDataProcessed = false;
+ while (ccData.bytesLeft() >= packetLength) {
+ byte ccHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER
+ : (byte) ccData.readUnsignedByte();
+ int ccByte1 = ccData.readUnsignedByte();
+ int ccByte2 = ccData.readUnsignedByte();
+
+ // TODO: We're currently ignoring the top 5 marker bits, which should all be 1s according
+ // to the CEA-608 specification. We need to determine if the data should be handled
+ // differently when that is not the case.
+
+ if ((ccHeader & CC_TYPE_FLAG) != 0) {
+ // Do not process anything that is not part of the 608 byte stream.
+ continue;
+ }
+
+ if ((ccHeader & CC_FIELD_FLAG) != selectedField) {
+ // Do not process packets not within the selected field.
+ continue;
+ }
+
+ // Strip the parity bit from each byte to get CC data.
+ byte ccData1 = (byte) (ccByte1 & 0x7F);
+ byte ccData2 = (byte) (ccByte2 & 0x7F);
+
+ if (ccData1 == 0 && ccData2 == 0) {
+ // Ignore empty captions.
+ continue;
+ }
+
+ boolean previousIsCaptionValid = isCaptionValid;
+ isCaptionValid =
+ (ccHeader & CC_VALID_FLAG) == CC_VALID_FLAG
+ && ODD_PARITY_BYTE_TABLE[ccByte1]
+ && ODD_PARITY_BYTE_TABLE[ccByte2];
+
+ if (isRepeatedCommand(isCaptionValid, ccData1, ccData2)) {
+ // Ignore repeated valid commands.
+ continue;
+ }
+
+ if (!isCaptionValid) {
+ if (previousIsCaptionValid) {
+ // The encoder has flipped the validity bit to indicate captions are being turned off.
+ resetCueBuilders();
+ captionDataProcessed = true;
+ }
+ continue;
+ }
+
+ maybeUpdateIsInCaptionService(ccData1, ccData2);
+ if (!isInCaptionService) {
+ // Only the Captioning service is supported. Drop all other bytes.
+ continue;
+ }
+
+ if (!updateAndVerifyCurrentChannel(ccData1)) {
+ // Wrong channel.
+ continue;
+ }
+
+ if (isCtrlCode(ccData1)) {
+ if (isSpecialNorthAmericanChar(ccData1, ccData2)) {
+ currentCueBuilder.append(getSpecialNorthAmericanChar(ccData2));
+ } else if (isExtendedWestEuropeanChar(ccData1, ccData2)) {
+ // Remove standard equivalent of the special extended char before appending new one.
+ currentCueBuilder.backspace();
+ currentCueBuilder.append(getExtendedWestEuropeanChar(ccData1, ccData2));
+ } else if (isMidrowCtrlCode(ccData1, ccData2)) {
+ handleMidrowCtrl(ccData2);
+ } else if (isPreambleAddressCode(ccData1, ccData2)) {
+ handlePreambleAddressCode(ccData1, ccData2);
+ } else if (isTabCtrlCode(ccData1, ccData2)) {
+ currentCueBuilder.tabOffset = ccData2 - 0x20;
+ } else if (isMiscCode(ccData1, ccData2)) {
+ handleMiscCode(ccData2);
+ }
+ } else {
+ // Basic North American character set.
+ currentCueBuilder.append(getBasicChar(ccData1));
+ if ((ccData2 & 0xE0) != 0x00) {
+ currentCueBuilder.append(getBasicChar(ccData2));
+ }
+ }
+ captionDataProcessed = true;
+ }
+
+ if (captionDataProcessed) {
+ if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) {
+ cues = getDisplayCues();
+ }
+ }
+ }
+
+ private boolean updateAndVerifyCurrentChannel(byte cc1) {
+ if (isCtrlCode(cc1)) {
+ currentChannel = getChannel(cc1);
+ }
+ return currentChannel == selectedChannel;
+ }
+
+ private boolean isRepeatedCommand(boolean captionValid, byte cc1, byte cc2) {
+ // Most control commands are sent twice in succession to ensure they are received properly. We
+ // don't want to process duplicate commands, so if we see the same repeatable command twice in a
+ // row then we ignore the second one.
+ if (captionValid && isRepeatable(cc1)) {
+ if (repeatableControlSet && repeatableControlCc1 == cc1 && repeatableControlCc2 == cc2) {
+ // This is a repeated command, so we ignore it.
+ repeatableControlSet = false;
+ return true;
+ } else {
+ // This is the first occurrence of a repeatable command. Set the repeatable control
+ // variables so that we can recognize and ignore a duplicate (if there is one), and then
+ // continue to process the command below.
+ repeatableControlSet = true;
+ repeatableControlCc1 = cc1;
+ repeatableControlCc2 = cc2;
+ }
+ } else {
+ // This command is not repeatable.
+ repeatableControlSet = false;
+ }
+ return false;
+ }
+
+ private void handleMidrowCtrl(byte cc2) {
+ // TODO: support the extended styles (i.e. backgrounds and transparencies)
+
+ // A midrow control code advances the cursor.
+ currentCueBuilder.append(' ');
+
+ // cc2 - 0|0|1|0|STYLE|U
+ boolean underline = (cc2 & 0x01) == 0x01;
+ int style = (cc2 >> 1) & 0x07;
+ currentCueBuilder.setStyle(style, underline);
+ }
+
+ private void handlePreambleAddressCode(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|E|ROW
+ // C is the channel toggle, E is the extended flag, and ROW is the encoded row
+ int row = ROW_INDICES[cc1 & 0x07];
+ // TODO: support the extended address and style
+
+ // cc2 - 0|1|N|ATTRBTE|U
+ // N is the next row down toggle, ATTRBTE is the 4-byte encoded attribute, and U is the
+ // underline toggle.
+ boolean nextRowDown = (cc2 & 0x20) != 0;
+ if (nextRowDown) {
+ row++;
+ }
+
+ if (row != currentCueBuilder.row) {
+ if (captionMode != CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) {
+ currentCueBuilder = new CueBuilder(captionMode, captionRowCount);
+ cueBuilders.add(currentCueBuilder);
+ }
+ currentCueBuilder.row = row;
+ }
+
+ // cc2 - 0|1|N|0|STYLE|U
+ // cc2 - 0|1|N|1|CURSR|U
+ boolean isCursor = (cc2 & 0x10) == 0x10;
+ boolean underline = (cc2 & 0x01) == 0x01;
+ int cursorOrStyle = (cc2 >> 1) & 0x07;
+
+ // We need to call setStyle even for the isCursor case, to update the underline bit.
+ // STYLE_UNCHANGED is used for this case.
+ currentCueBuilder.setStyle(isCursor ? STYLE_UNCHANGED : cursorOrStyle, underline);
+
+ if (isCursor) {
+ currentCueBuilder.indent = COLUMN_INDICES[cursorOrStyle];
+ }
+ }
+
+ private void handleMiscCode(byte cc2) {
+ switch (cc2) {
+ case CTRL_ROLL_UP_CAPTIONS_2_ROWS:
+ setCaptionMode(CC_MODE_ROLL_UP);
+ setCaptionRowCount(2);
+ return;
+ case CTRL_ROLL_UP_CAPTIONS_3_ROWS:
+ setCaptionMode(CC_MODE_ROLL_UP);
+ setCaptionRowCount(3);
+ return;
+ case CTRL_ROLL_UP_CAPTIONS_4_ROWS:
+ setCaptionMode(CC_MODE_ROLL_UP);
+ setCaptionRowCount(4);
+ return;
+ case CTRL_RESUME_CAPTION_LOADING:
+ setCaptionMode(CC_MODE_POP_ON);
+ return;
+ case CTRL_RESUME_DIRECT_CAPTIONING:
+ setCaptionMode(CC_MODE_PAINT_ON);
+ return;
+ default:
+ // Fall through.
+ break;
+ }
+
+ if (captionMode == CC_MODE_UNKNOWN) {
+ return;
+ }
+
+ switch (cc2) {
+ case CTRL_ERASE_DISPLAYED_MEMORY:
+ cues = Collections.emptyList();
+ if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) {
+ resetCueBuilders();
+ }
+ break;
+ case CTRL_ERASE_NON_DISPLAYED_MEMORY:
+ resetCueBuilders();
+ break;
+ case CTRL_END_OF_CAPTION:
+ cues = getDisplayCues();
+ resetCueBuilders();
+ break;
+ case CTRL_CARRIAGE_RETURN:
+ // carriage returns only apply to rollup captions; don't bother if we don't have anything
+ // to add a carriage return to
+ if (captionMode == CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) {
+ currentCueBuilder.rollUp();
+ }
+ break;
+ case CTRL_BACKSPACE:
+ currentCueBuilder.backspace();
+ break;
+ case CTRL_DELETE_TO_END_OF_ROW:
+ // TODO: implement
+ break;
+ default:
+ // Fall through.
+ break;
+ }
+ }
+
+ private List<Cue> getDisplayCues() {
+ // CEA-608 does not define middle and end alignment, however content providers artificially
+ // introduce them using whitespace. When each cue is built, we try and infer the alignment based
+ // on the amount of whitespace either side of the text. To avoid consecutive cues being aligned
+ // differently, we force all cues to have the same alignment, with start alignment given
+ // preference, then middle alignment, then end alignment.
+ @Cue.AnchorType int positionAnchor = Cue.ANCHOR_TYPE_END;
+ int cueBuilderCount = cueBuilders.size();
+ List<Cue> cueBuilderCues = new ArrayList<>(cueBuilderCount);
+ for (int i = 0; i < cueBuilderCount; i++) {
+ Cue cue = cueBuilders.get(i).build(/* forcedPositionAnchor= */ Cue.TYPE_UNSET);
+ cueBuilderCues.add(cue);
+ if (cue != null) {
+ positionAnchor = Math.min(positionAnchor, cue.positionAnchor);
+ }
+ }
+
+ // Skip null cues and rebuild any that don't have the preferred alignment.
+ List<Cue> displayCues = new ArrayList<>(cueBuilderCount);
+ for (int i = 0; i < cueBuilderCount; i++) {
+ Cue cue = cueBuilderCues.get(i);
+ if (cue != null) {
+ if (cue.positionAnchor != positionAnchor) {
+ cue = cueBuilders.get(i).build(positionAnchor);
+ }
+ displayCues.add(cue);
+ }
+ }
+
+ return displayCues;
+ }
+
+ private void setCaptionMode(int captionMode) {
+ if (this.captionMode == captionMode) {
+ return;
+ }
+
+ int oldCaptionMode = this.captionMode;
+ this.captionMode = captionMode;
+
+ if (captionMode == CC_MODE_PAINT_ON) {
+ // Switching to paint-on mode should have no effect except to select the mode.
+ for (int i = 0; i < cueBuilders.size(); i++) {
+ cueBuilders.get(i).setCaptionMode(captionMode);
+ }
+ return;
+ }
+
+ // Clear the working memory.
+ resetCueBuilders();
+ if (oldCaptionMode == CC_MODE_PAINT_ON || captionMode == CC_MODE_ROLL_UP
+ || captionMode == CC_MODE_UNKNOWN) {
+ // When switching from paint-on or to roll-up or unknown, we also need to clear the caption.
+ cues = Collections.emptyList();
+ }
+ }
+
+ private void setCaptionRowCount(int captionRowCount) {
+ this.captionRowCount = captionRowCount;
+ currentCueBuilder.setCaptionRowCount(captionRowCount);
+ }
+
+ private void resetCueBuilders() {
+ currentCueBuilder.reset(captionMode);
+ cueBuilders.clear();
+ cueBuilders.add(currentCueBuilder);
+ }
+
+ private void maybeUpdateIsInCaptionService(byte cc1, byte cc2) {
+ if (isXdsControlCode(cc1)) {
+ isInCaptionService = false;
+ } else if (isServiceSwitchCommand(cc1)) {
+ switch (cc2) {
+ case CTRL_TEXT_RESTART:
+ case CTRL_RESUME_TEXT_DISPLAY:
+ isInCaptionService = false;
+ break;
+ case CTRL_END_OF_CAPTION:
+ case CTRL_RESUME_CAPTION_LOADING:
+ case CTRL_RESUME_DIRECT_CAPTIONING:
+ case CTRL_ROLL_UP_CAPTIONS_2_ROWS:
+ case CTRL_ROLL_UP_CAPTIONS_3_ROWS:
+ case CTRL_ROLL_UP_CAPTIONS_4_ROWS:
+ isInCaptionService = true;
+ break;
+ default:
+ // No update.
+ }
+ }
+ }
+
+ private static char getBasicChar(byte ccData) {
+ int index = (ccData & 0x7F) - 0x20;
+ return (char) BASIC_CHARACTER_SET[index];
+ }
+
+ private static boolean isSpecialNorthAmericanChar(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|0|0|1
+ // cc2 - 0|0|1|1|X|X|X|X
+ return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x30);
+ }
+
+ private static char getSpecialNorthAmericanChar(byte ccData) {
+ int index = ccData & 0x0F;
+ return (char) SPECIAL_CHARACTER_SET[index];
+ }
+
+ private static boolean isExtendedWestEuropeanChar(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|0|1|S
+ // cc2 - 0|0|1|X|X|X|X|X
+ return ((cc1 & 0xF6) == 0x12) && ((cc2 & 0xE0) == 0x20);
+ }
+
+ private static char getExtendedWestEuropeanChar(byte cc1, byte cc2) {
+ if ((cc1 & 0x01) == 0x00) {
+ // Extended Spanish/Miscellaneous and French character set (S = 0).
+ return getExtendedEsFrChar(cc2);
+ } else {
+ // Extended Portuguese and German/Danish character set (S = 1).
+ return getExtendedPtDeChar(cc2);
+ }
+ }
+
+ private static char getExtendedEsFrChar(byte ccData) {
+ int index = ccData & 0x1F;
+ return (char) SPECIAL_ES_FR_CHARACTER_SET[index];
+ }
+
+ private static char getExtendedPtDeChar(byte ccData) {
+ int index = ccData & 0x1F;
+ return (char) SPECIAL_PT_DE_CHARACTER_SET[index];
+ }
+
+ private static boolean isCtrlCode(byte cc1) {
+ // cc1 - 0|0|0|X|X|X|X|X
+ return (cc1 & 0xE0) == 0x00;
+ }
+
+ private static int getChannel(byte cc1) {
+ // cc1 - X|X|X|X|C|X|X|X
+ return (cc1 >> 3) & 0x1;
+ }
+
+ private static boolean isMidrowCtrlCode(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|0|0|1
+ // cc2 - 0|0|1|0|X|X|X|X
+ return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x20);
+ }
+
+ private static boolean isPreambleAddressCode(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|X|X|X
+ // cc2 - 0|1|X|X|X|X|X|X
+ return ((cc1 & 0xF0) == 0x10) && ((cc2 & 0xC0) == 0x40);
+ }
+
+ private static boolean isTabCtrlCode(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|1|1|1
+ // cc2 - 0|0|1|0|0|0|0|1 to 0|0|1|0|0|0|1|1
+ return ((cc1 & 0xF7) == 0x17) && (cc2 >= 0x21 && cc2 <= 0x23);
+ }
+
+ private static boolean isMiscCode(byte cc1, byte cc2) {
+ // cc1 - 0|0|0|1|C|1|0|F
+ // cc2 - 0|0|1|0|X|X|X|X
+ return ((cc1 & 0xF6) == 0x14) && ((cc2 & 0xF0) == 0x20);
+ }
+
+ private static boolean isRepeatable(byte cc1) {
+ // cc1 - 0|0|0|1|X|X|X|X
+ return (cc1 & 0xF0) == 0x10;
+ }
+
+ private static boolean isXdsControlCode(byte cc1) {
+ return 0x01 <= cc1 && cc1 <= 0x0F;
+ }
+
+ private static boolean isServiceSwitchCommand(byte cc1) {
+ // cc1 - 0|0|0|1|C|1|0|0
+ return (cc1 & 0xF7) == 0x14;
+ }
+
+ private static class CueBuilder {
+
+ // 608 captions define a 15 row by 32 column screen grid. These constants convert from 608
+ // positions to normalized screen position.
+ private static final int SCREEN_CHARWIDTH = 32;
+ private static final int BASE_ROW = 15;
+
+ private final List<CueStyle> cueStyles;
+ private final List<SpannableString> rolledUpCaptions;
+ private final StringBuilder captionStringBuilder;
+
+ private int row;
+ private int indent;
+ private int tabOffset;
+ private int captionMode;
+ private int captionRowCount;
+
+ public CueBuilder(int captionMode, int captionRowCount) {
+ cueStyles = new ArrayList<>();
+ rolledUpCaptions = new ArrayList<>();
+ captionStringBuilder = new StringBuilder();
+ reset(captionMode);
+ setCaptionRowCount(captionRowCount);
+ }
+
+ public void reset(int captionMode) {
+ this.captionMode = captionMode;
+ cueStyles.clear();
+ rolledUpCaptions.clear();
+ captionStringBuilder.setLength(0);
+ row = BASE_ROW;
+ indent = 0;
+ tabOffset = 0;
+ }
+
+ public boolean isEmpty() {
+ return cueStyles.isEmpty()
+ && rolledUpCaptions.isEmpty()
+ && captionStringBuilder.length() == 0;
+ }
+
+ public void setCaptionMode(int captionMode) {
+ this.captionMode = captionMode;
+ }
+
+ public void setCaptionRowCount(int captionRowCount) {
+ this.captionRowCount = captionRowCount;
+ }
+
+ public void setStyle(int style, boolean underline) {
+ cueStyles.add(new CueStyle(style, underline, captionStringBuilder.length()));
+ }
+
+ public void backspace() {
+ int length = captionStringBuilder.length();
+ if (length > 0) {
+ captionStringBuilder.delete(length - 1, length);
+ // Decrement style start positions if necessary.
+ for (int i = cueStyles.size() - 1; i >= 0; i--) {
+ CueStyle style = cueStyles.get(i);
+ if (style.start == length) {
+ style.start--;
+ } else {
+ // All earlier cues must have style.start < length.
+ break;
+ }
+ }
+ }
+ }
+
+ public void append(char text) {
+ captionStringBuilder.append(text);
+ }
+
+ public void rollUp() {
+ rolledUpCaptions.add(buildCurrentLine());
+ captionStringBuilder.setLength(0);
+ cueStyles.clear();
+ int numRows = Math.min(captionRowCount, row);
+ while (rolledUpCaptions.size() >= numRows) {
+ rolledUpCaptions.remove(0);
+ }
+ }
+
+ public Cue build(@Cue.AnchorType int forcedPositionAnchor) {
+ SpannableStringBuilder cueString = new SpannableStringBuilder();
+ // Add any rolled up captions, separated by new lines.
+ for (int i = 0; i < rolledUpCaptions.size(); i++) {
+ cueString.append(rolledUpCaptions.get(i));
+ cueString.append('\n');
+ }
+ // Add the current line.
+ cueString.append(buildCurrentLine());
+
+ if (cueString.length() == 0) {
+ // The cue is empty.
+ return null;
+ }
+
+ int positionAnchor;
+ // The number of empty columns before the start of the text, in the range [0-31].
+ int startPadding = indent + tabOffset;
+ // The number of empty columns after the end of the text, in the same range.
+ int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length();
+ int startEndPaddingDelta = startPadding - endPadding;
+ if (forcedPositionAnchor != Cue.TYPE_UNSET) {
+ positionAnchor = forcedPositionAnchor;
+ } else if (captionMode == CC_MODE_POP_ON
+ && (Math.abs(startEndPaddingDelta) < 3 || endPadding < 0)) {
+ // Treat approximately centered pop-on captions as middle aligned. We also treat captions
+ // that are wider than they should be in this way. See
+ // https://github.com/google/ExoPlayer/issues/3534.
+ positionAnchor = Cue.ANCHOR_TYPE_MIDDLE;
+ } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) {
+ // Treat pop-on captions with less padding at the end than the start as end aligned.
+ positionAnchor = Cue.ANCHOR_TYPE_END;
+ } else {
+ // For all other cases assume start aligned.
+ positionAnchor = Cue.ANCHOR_TYPE_START;
+ }
+
+ float position;
+ switch (positionAnchor) {
+ case Cue.ANCHOR_TYPE_MIDDLE:
+ position = 0.5f;
+ break;
+ case Cue.ANCHOR_TYPE_END:
+ position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH;
+ // Adjust the position to fit within the safe area.
+ position = position * 0.8f + 0.1f;
+ break;
+ case Cue.ANCHOR_TYPE_START:
+ default:
+ position = (float) startPadding / SCREEN_CHARWIDTH;
+ // Adjust the position to fit within the safe area.
+ position = position * 0.8f + 0.1f;
+ break;
+ }
+
+ int lineAnchor;
+ int line;
+ // Note: Row indices are in the range [1-15].
+ if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) {
+ lineAnchor = Cue.ANCHOR_TYPE_END;
+ line = row - BASE_ROW;
+ // Two line adjustments. The first is because line indices from the bottom of the window
+ // start from -1 rather than 0. The second is a blank row to act as the safe area.
+ line -= 2;
+ } else {
+ lineAnchor = Cue.ANCHOR_TYPE_START;
+ // Line indices from the top of the window start from 0, but we want a blank row to act as
+ // the safe area. As a result no adjustment is necessary.
+ line = row;
+ }
+
+ return new Cue(
+ cueString,
+ Alignment.ALIGN_NORMAL,
+ line,
+ Cue.LINE_TYPE_NUMBER,
+ lineAnchor,
+ position,
+ positionAnchor,
+ Cue.DIMEN_UNSET);
+ }
+
+ private SpannableString buildCurrentLine() {
+ SpannableStringBuilder builder = new SpannableStringBuilder(captionStringBuilder);
+ int length = builder.length();
+
+ int underlineStartPosition = C.INDEX_UNSET;
+ int italicStartPosition = C.INDEX_UNSET;
+ int colorStartPosition = 0;
+ int color = Color.WHITE;
+
+ boolean nextItalic = false;
+ int nextColor = Color.WHITE;
+
+ for (int i = 0; i < cueStyles.size(); i++) {
+ CueStyle cueStyle = cueStyles.get(i);
+ boolean underline = cueStyle.underline;
+ int style = cueStyle.style;
+ if (style != STYLE_UNCHANGED) {
+ // If the style is a color then italic is cleared.
+ nextItalic = style == STYLE_ITALICS;
+ // If the style is italic then the color is left unchanged.
+ nextColor = style == STYLE_ITALICS ? nextColor : STYLE_COLORS[style];
+ }
+
+ int position = cueStyle.start;
+ int nextPosition = (i + 1) < cueStyles.size() ? cueStyles.get(i + 1).start : length;
+ if (position == nextPosition) {
+ // There are more cueStyles to process at the current position.
+ continue;
+ }
+
+ // Process changes to underline up to the current position.
+ if (underlineStartPosition != C.INDEX_UNSET && !underline) {
+ setUnderlineSpan(builder, underlineStartPosition, position);
+ underlineStartPosition = C.INDEX_UNSET;
+ } else if (underlineStartPosition == C.INDEX_UNSET && underline) {
+ underlineStartPosition = position;
+ }
+ // Process changes to italic up to the current position.
+ if (italicStartPosition != C.INDEX_UNSET && !nextItalic) {
+ setItalicSpan(builder, italicStartPosition, position);
+ italicStartPosition = C.INDEX_UNSET;
+ } else if (italicStartPosition == C.INDEX_UNSET && nextItalic) {
+ italicStartPosition = position;
+ }
+ // Process changes to color up to the current position.
+ if (nextColor != color) {
+ setColorSpan(builder, colorStartPosition, position, color);
+ color = nextColor;
+ colorStartPosition = position;
+ }
+ }
+
+ // Add any final spans.
+ if (underlineStartPosition != C.INDEX_UNSET && underlineStartPosition != length) {
+ setUnderlineSpan(builder, underlineStartPosition, length);
+ }
+ if (italicStartPosition != C.INDEX_UNSET && italicStartPosition != length) {
+ setItalicSpan(builder, italicStartPosition, length);
+ }
+ if (colorStartPosition != length) {
+ setColorSpan(builder, colorStartPosition, length, color);
+ }
+
+ return new SpannableString(builder);
+ }
+
+ private static void setUnderlineSpan(SpannableStringBuilder builder, int start, int end) {
+ builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ private static void setItalicSpan(SpannableStringBuilder builder, int start, int end) {
+ builder.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ private static void setColorSpan(
+ SpannableStringBuilder builder, int start, int end, int color) {
+ if (color == Color.WHITE) {
+ // White is treated as the default color (i.e. no span is attached).
+ return;
+ }
+ builder.setSpan(new ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ private static class CueStyle {
+
+ public final int style;
+ public final boolean underline;
+
+ public int start;
+
+ public CueStyle(int style, boolean underline, int start) {
+ this.style = style;
+ this.underline = underline;
+ this.start = start;
+ }
+
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java
new file mode 100644
index 0000000000..268b6baec0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java
@@ -0,0 +1,62 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import android.text.Layout.Alignment;
+import androidx.annotation.NonNull;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+
+/**
+ * A {@link Cue} for CEA-708.
+ */
+/* package */ final class Cea708Cue extends Cue implements Comparable<Cea708Cue> {
+
+ /**
+ * The priority of the cue box.
+ */
+ public final int priority;
+
+ /**
+ * @param text See {@link #text}.
+ * @param textAlignment See {@link #textAlignment}.
+ * @param line See {@link #line}.
+ * @param lineType See {@link #lineType}.
+ * @param lineAnchor See {@link #lineAnchor}.
+ * @param position See {@link #position}.
+ * @param positionAnchor See {@link #positionAnchor}.
+ * @param size See {@link #size}.
+ * @param windowColorSet See {@link #windowColorSet}.
+ * @param windowColor See {@link #windowColor}.
+ * @param priority See (@link #priority}.
+ */
+ public Cea708Cue(CharSequence text, Alignment textAlignment, float line, @LineType int lineType,
+ @AnchorType int lineAnchor, float position, @AnchorType int positionAnchor, float size,
+ boolean windowColorSet, int windowColor, int priority) {
+ super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, size,
+ windowColorSet, windowColor);
+ this.priority = priority;
+ }
+
+ @Override
+ public int compareTo(@NonNull Cea708Cue other) {
+ if (other.priority < priority) {
+ return -1;
+ } else if (other.priority > priority) {
+ return 1;
+ }
+ return 0;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java
new file mode 100644
index 0000000000..c8af0ed350
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java
@@ -0,0 +1,1255 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.text.Layout.Alignment;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.UnderlineSpan;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue.AnchorType;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A {@link SubtitleDecoder} for CEA-708 (also known as "EIA-708").
+ */
+public final class Cea708Decoder extends CeaDecoder {
+
+ private static final String TAG = "Cea708Decoder";
+
+ private static final int NUM_WINDOWS = 8;
+
+ private static final int DTVCC_PACKET_DATA = 0x02;
+ private static final int DTVCC_PACKET_START = 0x03;
+ private static final int CC_VALID_FLAG = 0x04;
+
+ // Base Commands
+ private static final int GROUP_C0_END = 0x1F; // Miscellaneous Control Codes
+ private static final int GROUP_G0_END = 0x7F; // ASCII Printable Characters
+ private static final int GROUP_C1_END = 0x9F; // Captioning Command Control Codes
+ private static final int GROUP_G1_END = 0xFF; // ISO 8859-1 LATIN-1 Character Set
+
+ // Extended Commands
+ private static final int GROUP_C2_END = 0x1F; // Extended Control Code Set 1
+ private static final int GROUP_G2_END = 0x7F; // Extended Miscellaneous Characters
+ private static final int GROUP_C3_END = 0x9F; // Extended Control Code Set 2
+ private static final int GROUP_G3_END = 0xFF; // Future Expansion
+
+ // Group C0 Commands
+ private static final int COMMAND_NUL = 0x00; // Nul
+ private static final int COMMAND_ETX = 0x03; // EndOfText
+ private static final int COMMAND_BS = 0x08; // Backspace
+ private static final int COMMAND_FF = 0x0C; // FormFeed (Flush)
+ private static final int COMMAND_CR = 0x0D; // CarriageReturn
+ private static final int COMMAND_HCR = 0x0E; // ClearLine
+ private static final int COMMAND_EXT1 = 0x10; // Extended Control Code Flag
+ private static final int COMMAND_EXT1_START = 0x11;
+ private static final int COMMAND_EXT1_END = 0x17;
+ private static final int COMMAND_P16_START = 0x18;
+ private static final int COMMAND_P16_END = 0x1F;
+
+ // Group C1 Commands
+ private static final int COMMAND_CW0 = 0x80; // SetCurrentWindow to 0
+ private static final int COMMAND_CW1 = 0x81; // SetCurrentWindow to 1
+ private static final int COMMAND_CW2 = 0x82; // SetCurrentWindow to 2
+ private static final int COMMAND_CW3 = 0x83; // SetCurrentWindow to 3
+ private static final int COMMAND_CW4 = 0x84; // SetCurrentWindow to 4
+ private static final int COMMAND_CW5 = 0x85; // SetCurrentWindow to 5
+ private static final int COMMAND_CW6 = 0x86; // SetCurrentWindow to 6
+ private static final int COMMAND_CW7 = 0x87; // SetCurrentWindow to 7
+ private static final int COMMAND_CLW = 0x88; // ClearWindows (+1 byte)
+ private static final int COMMAND_DSW = 0x89; // DisplayWindows (+1 byte)
+ private static final int COMMAND_HDW = 0x8A; // HideWindows (+1 byte)
+ private static final int COMMAND_TGW = 0x8B; // ToggleWindows (+1 byte)
+ private static final int COMMAND_DLW = 0x8C; // DeleteWindows (+1 byte)
+ private static final int COMMAND_DLY = 0x8D; // Delay (+1 byte)
+ private static final int COMMAND_DLC = 0x8E; // DelayCancel
+ private static final int COMMAND_RST = 0x8F; // Reset
+ private static final int COMMAND_SPA = 0x90; // SetPenAttributes (+2 bytes)
+ private static final int COMMAND_SPC = 0x91; // SetPenColor (+3 bytes)
+ private static final int COMMAND_SPL = 0x92; // SetPenLocation (+2 bytes)
+ private static final int COMMAND_SWA = 0x97; // SetWindowAttributes (+4 bytes)
+ private static final int COMMAND_DF0 = 0x98; // DefineWindow 0 (+6 bytes)
+ private static final int COMMAND_DF1 = 0x99; // DefineWindow 1 (+6 bytes)
+ private static final int COMMAND_DF2 = 0x9A; // DefineWindow 2 (+6 bytes)
+ private static final int COMMAND_DF3 = 0x9B; // DefineWindow 3 (+6 bytes)
+ private static final int COMMAND_DF4 = 0x9C; // DefineWindow 4 (+6 bytes)
+ private static final int COMMAND_DF5 = 0x9D; // DefineWindow 5 (+6 bytes)
+ private static final int COMMAND_DF6 = 0x9E; // DefineWindow 6 (+6 bytes)
+ private static final int COMMAND_DF7 = 0x9F; // DefineWindow 7 (+6 bytes)
+
+ // G0 Table Special Chars
+ private static final int CHARACTER_MN = 0x7F; // MusicNote
+
+ // G2 Table Special Chars
+ private static final int CHARACTER_TSP = 0x20;
+ private static final int CHARACTER_NBTSP = 0x21;
+ private static final int CHARACTER_ELLIPSIS = 0x25;
+ private static final int CHARACTER_BIG_CARONS = 0x2A;
+ private static final int CHARACTER_BIG_OE = 0x2C;
+ private static final int CHARACTER_SOLID_BLOCK = 0x30;
+ private static final int CHARACTER_OPEN_SINGLE_QUOTE = 0x31;
+ private static final int CHARACTER_CLOSE_SINGLE_QUOTE = 0x32;
+ private static final int CHARACTER_OPEN_DOUBLE_QUOTE = 0x33;
+ private static final int CHARACTER_CLOSE_DOUBLE_QUOTE = 0x34;
+ private static final int CHARACTER_BOLD_BULLET = 0x35;
+ private static final int CHARACTER_TM = 0x39;
+ private static final int CHARACTER_SMALL_CARONS = 0x3A;
+ private static final int CHARACTER_SMALL_OE = 0x3C;
+ private static final int CHARACTER_SM = 0x3D;
+ private static final int CHARACTER_DIAERESIS_Y = 0x3F;
+ private static final int CHARACTER_ONE_EIGHTH = 0x76;
+ private static final int CHARACTER_THREE_EIGHTHS = 0x77;
+ private static final int CHARACTER_FIVE_EIGHTHS = 0x78;
+ private static final int CHARACTER_SEVEN_EIGHTHS = 0x79;
+ private static final int CHARACTER_VERTICAL_BORDER = 0x7A;
+ private static final int CHARACTER_UPPER_RIGHT_BORDER = 0x7B;
+ private static final int CHARACTER_LOWER_LEFT_BORDER = 0x7C;
+ private static final int CHARACTER_HORIZONTAL_BORDER = 0x7D;
+ private static final int CHARACTER_LOWER_RIGHT_BORDER = 0x7E;
+ private static final int CHARACTER_UPPER_LEFT_BORDER = 0x7F;
+
+ private final ParsableByteArray ccData;
+ private final ParsableBitArray serviceBlockPacket;
+
+ private final int selectedServiceNumber;
+ private final CueBuilder[] cueBuilders;
+
+ private CueBuilder currentCueBuilder;
+ private List<Cue> cues;
+ private List<Cue> lastCues;
+
+ private DtvCcPacket currentDtvCcPacket;
+ private int currentWindow;
+
+ // TODO: Retrieve isWideAspectRatio from initializationData and use it.
+ public Cea708Decoder(int accessibilityChannel, @Nullable List<byte[]> initializationData) {
+ ccData = new ParsableByteArray();
+ serviceBlockPacket = new ParsableBitArray();
+ selectedServiceNumber = accessibilityChannel == Format.NO_VALUE ? 1 : accessibilityChannel;
+
+ cueBuilders = new CueBuilder[NUM_WINDOWS];
+ for (int i = 0; i < NUM_WINDOWS; i++) {
+ cueBuilders[i] = new CueBuilder();
+ }
+
+ currentCueBuilder = cueBuilders[0];
+ resetCueBuilders();
+ }
+
+ @Override
+ public String getName() {
+ return "Cea708Decoder";
+ }
+
+ @Override
+ public void flush() {
+ super.flush();
+ cues = null;
+ lastCues = null;
+ currentWindow = 0;
+ currentCueBuilder = cueBuilders[currentWindow];
+ resetCueBuilders();
+ currentDtvCcPacket = null;
+ }
+
+ @Override
+ protected boolean isNewSubtitleDataAvailable() {
+ return cues != lastCues;
+ }
+
+ @Override
+ protected Subtitle createSubtitle() {
+ lastCues = cues;
+ return new CeaSubtitle(cues);
+ }
+
+ @Override
+ protected void decode(SubtitleInputBuffer inputBuffer) {
+ // Subtitle input buffers are non-direct and the position is zero, so calling array() is safe.
+ @SuppressWarnings("ByteBufferBackingArray")
+ byte[] inputBufferData = inputBuffer.data.array();
+ ccData.reset(inputBufferData, inputBuffer.data.limit());
+ while (ccData.bytesLeft() >= 3) {
+ int ccTypeAndValid = (ccData.readUnsignedByte() & 0x07);
+
+ int ccType = ccTypeAndValid & (DTVCC_PACKET_DATA | DTVCC_PACKET_START);
+ boolean ccValid = (ccTypeAndValid & CC_VALID_FLAG) == CC_VALID_FLAG;
+ byte ccData1 = (byte) ccData.readUnsignedByte();
+ byte ccData2 = (byte) ccData.readUnsignedByte();
+
+ // Ignore any non-CEA-708 data
+ if (ccType != DTVCC_PACKET_DATA && ccType != DTVCC_PACKET_START) {
+ continue;
+ }
+
+ if (!ccValid) {
+ // This byte-pair isn't valid, ignore it and continue.
+ continue;
+ }
+
+ if (ccType == DTVCC_PACKET_START) {
+ finalizeCurrentPacket();
+
+ int sequenceNumber = (ccData1 & 0xC0) >> 6; // first 2 bits
+ int packetSize = ccData1 & 0x3F; // last 6 bits
+ if (packetSize == 0) {
+ packetSize = 64;
+ }
+
+ currentDtvCcPacket = new DtvCcPacket(sequenceNumber, packetSize);
+ currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2;
+ } else {
+ // The only remaining valid packet type is DTVCC_PACKET_DATA
+ Assertions.checkArgument(ccType == DTVCC_PACKET_DATA);
+
+ if (currentDtvCcPacket == null) {
+ Log.e(TAG, "Encountered DTVCC_PACKET_DATA before DTVCC_PACKET_START");
+ continue;
+ }
+
+ currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData1;
+ currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2;
+ }
+
+ if (currentDtvCcPacket.currentIndex == (currentDtvCcPacket.packetSize * 2 - 1)) {
+ finalizeCurrentPacket();
+ }
+ }
+ }
+
+ private void finalizeCurrentPacket() {
+ if (currentDtvCcPacket == null) {
+ // No packet to finalize;
+ return;
+ }
+
+ processCurrentPacket();
+ currentDtvCcPacket = null;
+ }
+
+ private void processCurrentPacket() {
+ if (currentDtvCcPacket.currentIndex != (currentDtvCcPacket.packetSize * 2 - 1)) {
+ Log.w(TAG, "DtvCcPacket ended prematurely; size is " + (currentDtvCcPacket.packetSize * 2 - 1)
+ + ", but current index is " + currentDtvCcPacket.currentIndex + " (sequence number "
+ + currentDtvCcPacket.sequenceNumber + "); ignoring packet");
+ return;
+ }
+
+ serviceBlockPacket.reset(currentDtvCcPacket.packetData, currentDtvCcPacket.currentIndex);
+
+ int serviceNumber = serviceBlockPacket.readBits(3);
+ int blockSize = serviceBlockPacket.readBits(5);
+ if (serviceNumber == 7) {
+ // extended service numbers
+ serviceBlockPacket.skipBits(2);
+ serviceNumber = serviceBlockPacket.readBits(6);
+ if (serviceNumber < 7) {
+ Log.w(TAG, "Invalid extended service number: " + serviceNumber);
+ }
+ }
+
+ // Ignore packets in which blockSize is 0
+ if (blockSize == 0) {
+ if (serviceNumber != 0) {
+ Log.w(TAG, "serviceNumber is non-zero (" + serviceNumber + ") when blockSize is 0");
+ }
+ return;
+ }
+
+ if (serviceNumber != selectedServiceNumber) {
+ return;
+ }
+
+ // The cues should be updated if we receive a C0 ETX command, any C1 command, or if after
+ // processing the service block any text has been added to the buffer. See CEA-708-B Section
+ // 8.10.4 for more details.
+ boolean cuesNeedUpdate = false;
+
+ while (serviceBlockPacket.bitsLeft() > 0) {
+ int command = serviceBlockPacket.readBits(8);
+ if (command != COMMAND_EXT1) {
+ if (command <= GROUP_C0_END) {
+ handleC0Command(command);
+ // If the C0 command was an ETX command, the cues are updated in handleC0Command.
+ } else if (command <= GROUP_G0_END) {
+ handleG0Character(command);
+ cuesNeedUpdate = true;
+ } else if (command <= GROUP_C1_END) {
+ handleC1Command(command);
+ cuesNeedUpdate = true;
+ } else if (command <= GROUP_G1_END) {
+ handleG1Character(command);
+ cuesNeedUpdate = true;
+ } else {
+ Log.w(TAG, "Invalid base command: " + command);
+ }
+ } else {
+ // Read the extended command
+ command = serviceBlockPacket.readBits(8);
+ if (command <= GROUP_C2_END) {
+ handleC2Command(command);
+ } else if (command <= GROUP_G2_END) {
+ handleG2Character(command);
+ cuesNeedUpdate = true;
+ } else if (command <= GROUP_C3_END) {
+ handleC3Command(command);
+ } else if (command <= GROUP_G3_END) {
+ handleG3Character(command);
+ cuesNeedUpdate = true;
+ } else {
+ Log.w(TAG, "Invalid extended command: " + command);
+ }
+ }
+ }
+
+ if (cuesNeedUpdate) {
+ cues = getDisplayCues();
+ }
+ }
+
+ private void handleC0Command(int command) {
+ switch (command) {
+ case COMMAND_NUL:
+ // Do nothing.
+ break;
+ case COMMAND_ETX:
+ cues = getDisplayCues();
+ break;
+ case COMMAND_BS:
+ currentCueBuilder.backspace();
+ break;
+ case COMMAND_FF:
+ resetCueBuilders();
+ break;
+ case COMMAND_CR:
+ currentCueBuilder.append('\n');
+ break;
+ case COMMAND_HCR:
+ // TODO: Add support for this command.
+ break;
+ default:
+ if (command >= COMMAND_EXT1_START && command <= COMMAND_EXT1_END) {
+ Log.w(TAG, "Currently unsupported COMMAND_EXT1 Command: " + command);
+ serviceBlockPacket.skipBits(8);
+ } else if (command >= COMMAND_P16_START && command <= COMMAND_P16_END) {
+ Log.w(TAG, "Currently unsupported COMMAND_P16 Command: " + command);
+ serviceBlockPacket.skipBits(16);
+ } else {
+ Log.w(TAG, "Invalid C0 command: " + command);
+ }
+ }
+ }
+
+ private void handleC1Command(int command) {
+ int window;
+ switch (command) {
+ case COMMAND_CW0:
+ case COMMAND_CW1:
+ case COMMAND_CW2:
+ case COMMAND_CW3:
+ case COMMAND_CW4:
+ case COMMAND_CW5:
+ case COMMAND_CW6:
+ case COMMAND_CW7:
+ window = (command - COMMAND_CW0);
+ if (currentWindow != window) {
+ currentWindow = window;
+ currentCueBuilder = cueBuilders[window];
+ }
+ break;
+ case COMMAND_CLW:
+ for (int i = 1; i <= NUM_WINDOWS; i++) {
+ if (serviceBlockPacket.readBit()) {
+ cueBuilders[NUM_WINDOWS - i].clear();
+ }
+ }
+ break;
+ case COMMAND_DSW:
+ for (int i = 1; i <= NUM_WINDOWS; i++) {
+ if (serviceBlockPacket.readBit()) {
+ cueBuilders[NUM_WINDOWS - i].setVisibility(true);
+ }
+ }
+ break;
+ case COMMAND_HDW:
+ for (int i = 1; i <= NUM_WINDOWS; i++) {
+ if (serviceBlockPacket.readBit()) {
+ cueBuilders[NUM_WINDOWS - i].setVisibility(false);
+ }
+ }
+ break;
+ case COMMAND_TGW:
+ for (int i = 1; i <= NUM_WINDOWS; i++) {
+ if (serviceBlockPacket.readBit()) {
+ CueBuilder cueBuilder = cueBuilders[NUM_WINDOWS - i];
+ cueBuilder.setVisibility(!cueBuilder.isVisible());
+ }
+ }
+ break;
+ case COMMAND_DLW:
+ for (int i = 1; i <= NUM_WINDOWS; i++) {
+ if (serviceBlockPacket.readBit()) {
+ cueBuilders[NUM_WINDOWS - i].reset();
+ }
+ }
+ break;
+ case COMMAND_DLY:
+ // TODO: Add support for delay commands.
+ serviceBlockPacket.skipBits(8);
+ break;
+ case COMMAND_DLC:
+ // TODO: Add support for delay commands.
+ break;
+ case COMMAND_RST:
+ resetCueBuilders();
+ break;
+ case COMMAND_SPA:
+ if (!currentCueBuilder.isDefined()) {
+ // ignore this command if the current window/cue isn't defined
+ serviceBlockPacket.skipBits(16);
+ } else {
+ handleSetPenAttributes();
+ }
+ break;
+ case COMMAND_SPC:
+ if (!currentCueBuilder.isDefined()) {
+ // ignore this command if the current window/cue isn't defined
+ serviceBlockPacket.skipBits(24);
+ } else {
+ handleSetPenColor();
+ }
+ break;
+ case COMMAND_SPL:
+ if (!currentCueBuilder.isDefined()) {
+ // ignore this command if the current window/cue isn't defined
+ serviceBlockPacket.skipBits(16);
+ } else {
+ handleSetPenLocation();
+ }
+ break;
+ case COMMAND_SWA:
+ if (!currentCueBuilder.isDefined()) {
+ // ignore this command if the current window/cue isn't defined
+ serviceBlockPacket.skipBits(32);
+ } else {
+ handleSetWindowAttributes();
+ }
+ break;
+ case COMMAND_DF0:
+ case COMMAND_DF1:
+ case COMMAND_DF2:
+ case COMMAND_DF3:
+ case COMMAND_DF4:
+ case COMMAND_DF5:
+ case COMMAND_DF6:
+ case COMMAND_DF7:
+ window = (command - COMMAND_DF0);
+ handleDefineWindow(window);
+ // We also set the current window to the newly defined window.
+ if (currentWindow != window) {
+ currentWindow = window;
+ currentCueBuilder = cueBuilders[window];
+ }
+ break;
+ default:
+ Log.w(TAG, "Invalid C1 command: " + command);
+ }
+ }
+
+ private void handleC2Command(int command) {
+ // C2 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes
+ if (command <= 0x07) {
+ // Do nothing.
+ } else if (command <= 0x0F) {
+ serviceBlockPacket.skipBits(8);
+ } else if (command <= 0x17) {
+ serviceBlockPacket.skipBits(16);
+ } else if (command <= 0x1F) {
+ serviceBlockPacket.skipBits(24);
+ }
+ }
+
+ private void handleC3Command(int command) {
+ // C3 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes
+ if (command <= 0x87) {
+ serviceBlockPacket.skipBits(32);
+ } else if (command <= 0x8F) {
+ serviceBlockPacket.skipBits(40);
+ } else if (command <= 0x9F) {
+ // 90-9F are variable length codes; the first byte defines the header with the first
+ // 2 bits specifying the type and the last 6 bits specifying the remaining length of the
+ // command in bytes
+ serviceBlockPacket.skipBits(2);
+ int length = serviceBlockPacket.readBits(6);
+ serviceBlockPacket.skipBits(8 * length);
+ }
+ }
+
+ private void handleG0Character(int characterCode) {
+ if (characterCode == CHARACTER_MN) {
+ currentCueBuilder.append('\u266B');
+ } else {
+ currentCueBuilder.append((char) (characterCode & 0xFF));
+ }
+ }
+
+ private void handleG1Character(int characterCode) {
+ currentCueBuilder.append((char) (characterCode & 0xFF));
+ }
+
+ private void handleG2Character(int characterCode) {
+ switch (characterCode) {
+ case CHARACTER_TSP:
+ currentCueBuilder.append('\u0020');
+ break;
+ case CHARACTER_NBTSP:
+ currentCueBuilder.append('\u00A0');
+ break;
+ case CHARACTER_ELLIPSIS:
+ currentCueBuilder.append('\u2026');
+ break;
+ case CHARACTER_BIG_CARONS:
+ currentCueBuilder.append('\u0160');
+ break;
+ case CHARACTER_BIG_OE:
+ currentCueBuilder.append('\u0152');
+ break;
+ case CHARACTER_SOLID_BLOCK:
+ currentCueBuilder.append('\u2588');
+ break;
+ case CHARACTER_OPEN_SINGLE_QUOTE:
+ currentCueBuilder.append('\u2018');
+ break;
+ case CHARACTER_CLOSE_SINGLE_QUOTE:
+ currentCueBuilder.append('\u2019');
+ break;
+ case CHARACTER_OPEN_DOUBLE_QUOTE:
+ currentCueBuilder.append('\u201C');
+ break;
+ case CHARACTER_CLOSE_DOUBLE_QUOTE:
+ currentCueBuilder.append('\u201D');
+ break;
+ case CHARACTER_BOLD_BULLET:
+ currentCueBuilder.append('\u2022');
+ break;
+ case CHARACTER_TM:
+ currentCueBuilder.append('\u2122');
+ break;
+ case CHARACTER_SMALL_CARONS:
+ currentCueBuilder.append('\u0161');
+ break;
+ case CHARACTER_SMALL_OE:
+ currentCueBuilder.append('\u0153');
+ break;
+ case CHARACTER_SM:
+ currentCueBuilder.append('\u2120');
+ break;
+ case CHARACTER_DIAERESIS_Y:
+ currentCueBuilder.append('\u0178');
+ break;
+ case CHARACTER_ONE_EIGHTH:
+ currentCueBuilder.append('\u215B');
+ break;
+ case CHARACTER_THREE_EIGHTHS:
+ currentCueBuilder.append('\u215C');
+ break;
+ case CHARACTER_FIVE_EIGHTHS:
+ currentCueBuilder.append('\u215D');
+ break;
+ case CHARACTER_SEVEN_EIGHTHS:
+ currentCueBuilder.append('\u215E');
+ break;
+ case CHARACTER_VERTICAL_BORDER:
+ currentCueBuilder.append('\u2502');
+ break;
+ case CHARACTER_UPPER_RIGHT_BORDER:
+ currentCueBuilder.append('\u2510');
+ break;
+ case CHARACTER_LOWER_LEFT_BORDER:
+ currentCueBuilder.append('\u2514');
+ break;
+ case CHARACTER_HORIZONTAL_BORDER:
+ currentCueBuilder.append('\u2500');
+ break;
+ case CHARACTER_LOWER_RIGHT_BORDER:
+ currentCueBuilder.append('\u2518');
+ break;
+ case CHARACTER_UPPER_LEFT_BORDER:
+ currentCueBuilder.append('\u250C');
+ break;
+ default:
+ Log.w(TAG, "Invalid G2 character: " + characterCode);
+ // The CEA-708 specification doesn't specify what to do in the case of an unexpected
+ // value in the G2 character range, so we ignore it.
+ }
+ }
+
+ private void handleG3Character(int characterCode) {
+ if (characterCode == 0xA0) {
+ currentCueBuilder.append('\u33C4');
+ } else {
+ Log.w(TAG, "Invalid G3 character: " + characterCode);
+ // Substitute any unsupported G3 character with an underscore as per CEA-708 specification.
+ currentCueBuilder.append('_');
+ }
+ }
+
+ private void handleSetPenAttributes() {
+ // the SetPenAttributes command contains 2 bytes of data
+ // first byte
+ int textTag = serviceBlockPacket.readBits(4);
+ int offset = serviceBlockPacket.readBits(2);
+ int penSize = serviceBlockPacket.readBits(2);
+ // second byte
+ boolean italicsToggle = serviceBlockPacket.readBit();
+ boolean underlineToggle = serviceBlockPacket.readBit();
+ int edgeType = serviceBlockPacket.readBits(3);
+ int fontStyle = serviceBlockPacket.readBits(3);
+
+ currentCueBuilder.setPenAttributes(textTag, offset, penSize, italicsToggle, underlineToggle,
+ edgeType, fontStyle);
+ }
+
+ private void handleSetPenColor() {
+ // the SetPenColor command contains 3 bytes of data
+ // first byte
+ int foregroundO = serviceBlockPacket.readBits(2);
+ int foregroundR = serviceBlockPacket.readBits(2);
+ int foregroundG = serviceBlockPacket.readBits(2);
+ int foregroundB = serviceBlockPacket.readBits(2);
+ int foregroundColor = CueBuilder.getArgbColorFromCeaColor(foregroundR, foregroundG, foregroundB,
+ foregroundO);
+ // second byte
+ int backgroundO = serviceBlockPacket.readBits(2);
+ int backgroundR = serviceBlockPacket.readBits(2);
+ int backgroundG = serviceBlockPacket.readBits(2);
+ int backgroundB = serviceBlockPacket.readBits(2);
+ int backgroundColor = CueBuilder.getArgbColorFromCeaColor(backgroundR, backgroundG, backgroundB,
+ backgroundO);
+ // third byte
+ serviceBlockPacket.skipBits(2); // null padding
+ int edgeR = serviceBlockPacket.readBits(2);
+ int edgeG = serviceBlockPacket.readBits(2);
+ int edgeB = serviceBlockPacket.readBits(2);
+ int edgeColor = CueBuilder.getArgbColorFromCeaColor(edgeR, edgeG, edgeB);
+
+ currentCueBuilder.setPenColor(foregroundColor, backgroundColor, edgeColor);
+ }
+
+ private void handleSetPenLocation() {
+ // the SetPenLocation command contains 2 bytes of data
+ // first byte
+ serviceBlockPacket.skipBits(4);
+ int row = serviceBlockPacket.readBits(4);
+ // second byte
+ serviceBlockPacket.skipBits(2);
+ int column = serviceBlockPacket.readBits(6);
+
+ currentCueBuilder.setPenLocation(row, column);
+ }
+
+ private void handleSetWindowAttributes() {
+ // the SetWindowAttributes command contains 4 bytes of data
+ // first byte
+ int fillO = serviceBlockPacket.readBits(2);
+ int fillR = serviceBlockPacket.readBits(2);
+ int fillG = serviceBlockPacket.readBits(2);
+ int fillB = serviceBlockPacket.readBits(2);
+ int fillColor = CueBuilder.getArgbColorFromCeaColor(fillR, fillG, fillB, fillO);
+ // second byte
+ int borderType = serviceBlockPacket.readBits(2); // only the lower 2 bits of borderType
+ int borderR = serviceBlockPacket.readBits(2);
+ int borderG = serviceBlockPacket.readBits(2);
+ int borderB = serviceBlockPacket.readBits(2);
+ int borderColor = CueBuilder.getArgbColorFromCeaColor(borderR, borderG, borderB);
+ // third byte
+ if (serviceBlockPacket.readBit()) {
+ borderType |= 0x04; // set the top bit of the 3-bit borderType
+ }
+ boolean wordWrapToggle = serviceBlockPacket.readBit();
+ int printDirection = serviceBlockPacket.readBits(2);
+ int scrollDirection = serviceBlockPacket.readBits(2);
+ int justification = serviceBlockPacket.readBits(2);
+ // fourth byte
+ // Note that we don't intend to support display effects
+ serviceBlockPacket.skipBits(8); // effectSpeed(4), effectDirection(2), displayEffect(2)
+
+ currentCueBuilder.setWindowAttributes(fillColor, borderColor, wordWrapToggle, borderType,
+ printDirection, scrollDirection, justification);
+ }
+
+ private void handleDefineWindow(int window) {
+ CueBuilder cueBuilder = cueBuilders[window];
+
+ // the DefineWindow command contains 6 bytes of data
+ // first byte
+ serviceBlockPacket.skipBits(2); // null padding
+ boolean visible = serviceBlockPacket.readBit();
+ boolean rowLock = serviceBlockPacket.readBit();
+ boolean columnLock = serviceBlockPacket.readBit();
+ int priority = serviceBlockPacket.readBits(3);
+ // second byte
+ boolean relativePositioning = serviceBlockPacket.readBit();
+ int verticalAnchor = serviceBlockPacket.readBits(7);
+ // third byte
+ int horizontalAnchor = serviceBlockPacket.readBits(8);
+ // fourth byte
+ int anchorId = serviceBlockPacket.readBits(4);
+ int rowCount = serviceBlockPacket.readBits(4);
+ // fifth byte
+ serviceBlockPacket.skipBits(2); // null padding
+ int columnCount = serviceBlockPacket.readBits(6);
+ // sixth byte
+ serviceBlockPacket.skipBits(2); // null padding
+ int windowStyle = serviceBlockPacket.readBits(3);
+ int penStyle = serviceBlockPacket.readBits(3);
+
+ cueBuilder.defineWindow(visible, rowLock, columnLock, priority, relativePositioning,
+ verticalAnchor, horizontalAnchor, rowCount, columnCount, anchorId, windowStyle, penStyle);
+ }
+
+ private List<Cue> getDisplayCues() {
+ List<Cea708Cue> displayCues = new ArrayList<>();
+ for (int i = 0; i < NUM_WINDOWS; i++) {
+ if (!cueBuilders[i].isEmpty() && cueBuilders[i].isVisible()) {
+ displayCues.add(cueBuilders[i].build());
+ }
+ }
+ Collections.sort(displayCues);
+ return Collections.unmodifiableList(displayCues);
+ }
+
+ private void resetCueBuilders() {
+ for (int i = 0; i < NUM_WINDOWS; i++) {
+ cueBuilders[i].reset();
+ }
+ }
+
+ private static final class DtvCcPacket {
+
+ public final int sequenceNumber;
+ public final int packetSize;
+ public final byte[] packetData;
+
+ int currentIndex;
+
+ public DtvCcPacket(int sequenceNumber, int packetSize) {
+ this.sequenceNumber = sequenceNumber;
+ this.packetSize = packetSize;
+ packetData = new byte[2 * packetSize - 1];
+ currentIndex = 0;
+ }
+
+ }
+
+ // TODO: There is a lot of overlap between Cea708Decoder.CueBuilder and Cea608Decoder.CueBuilder
+ // which could be refactored into a separate class.
+ private static final class CueBuilder {
+
+ private static final int RELATIVE_CUE_SIZE = 99;
+ private static final int VERTICAL_SIZE = 74;
+ private static final int HORIZONTAL_SIZE = 209;
+
+ private static final int DEFAULT_PRIORITY = 4;
+
+ private static final int MAXIMUM_ROW_COUNT = 15;
+
+ private static final int JUSTIFICATION_LEFT = 0;
+ private static final int JUSTIFICATION_RIGHT = 1;
+ private static final int JUSTIFICATION_CENTER = 2;
+ private static final int JUSTIFICATION_FULL = 3;
+
+ private static final int DIRECTION_LEFT_TO_RIGHT = 0;
+ private static final int DIRECTION_RIGHT_TO_LEFT = 1;
+ private static final int DIRECTION_TOP_TO_BOTTOM = 2;
+ private static final int DIRECTION_BOTTOM_TO_TOP = 3;
+
+ // TODO: Add other border/edge types when utilized.
+ private static final int BORDER_AND_EDGE_TYPE_NONE = 0;
+ private static final int BORDER_AND_EDGE_TYPE_UNIFORM = 3;
+
+ public static final int COLOR_SOLID_WHITE = getArgbColorFromCeaColor(2, 2, 2, 0);
+ public static final int COLOR_SOLID_BLACK = getArgbColorFromCeaColor(0, 0, 0, 0);
+ public static final int COLOR_TRANSPARENT = getArgbColorFromCeaColor(0, 0, 0, 3);
+
+ // TODO: Add other sizes when utilized.
+ private static final int PEN_SIZE_STANDARD = 1;
+
+ // TODO: Add other pen font styles when utilized.
+ private static final int PEN_FONT_STYLE_DEFAULT = 0;
+ private static final int PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS = 1;
+ private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS = 2;
+ private static final int PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS = 3;
+ private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS = 4;
+
+ // TODO: Add other pen offsets when utilized.
+ private static final int PEN_OFFSET_NORMAL = 1;
+
+ // The window style properties are specified in the CEA-708 specification.
+ private static final int[] WINDOW_STYLE_JUSTIFICATION = new int[] {
+ JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_LEFT,
+ JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_CENTER,
+ JUSTIFICATION_LEFT
+ };
+ private static final int[] WINDOW_STYLE_PRINT_DIRECTION = new int[] {
+ DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT,
+ DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT,
+ DIRECTION_TOP_TO_BOTTOM
+ };
+ private static final int[] WINDOW_STYLE_SCROLL_DIRECTION = new int[] {
+ DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP,
+ DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP,
+ DIRECTION_RIGHT_TO_LEFT
+ };
+ private static final boolean[] WINDOW_STYLE_WORD_WRAP = new boolean[] {
+ false, false, false, true, true, true, false
+ };
+ private static final int[] WINDOW_STYLE_FILL = new int[] {
+ COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK,
+ COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK
+ };
+
+ // The pen style properties are specified in the CEA-708 specification.
+ private static final int[] PEN_STYLE_FONT_STYLE = new int[] {
+ PEN_FONT_STYLE_DEFAULT, PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS,
+ PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS, PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS,
+ PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS,
+ PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS,
+ PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS
+ };
+ private static final int[] PEN_STYLE_EDGE_TYPE = new int[] {
+ BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE,
+ BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_UNIFORM,
+ BORDER_AND_EDGE_TYPE_UNIFORM
+ };
+ private static final int[] PEN_STYLE_BACKGROUND = new int[] {
+ COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK,
+ COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_TRANSPARENT};
+
+ private final List<SpannableString> rolledUpCaptions;
+ private final SpannableStringBuilder captionStringBuilder;
+
+ // Window/Cue properties
+ private boolean defined;
+ private boolean visible;
+ private int priority;
+ private boolean relativePositioning;
+ private int verticalAnchor;
+ private int horizontalAnchor;
+ private int anchorId;
+ private int rowCount;
+ private boolean rowLock;
+ private int justification;
+ private int windowStyleId;
+ private int penStyleId;
+ private int windowFillColor;
+
+ // Pen/Text properties
+ private int italicsStartPosition;
+ private int underlineStartPosition;
+ private int foregroundColorStartPosition;
+ private int foregroundColor;
+ private int backgroundColorStartPosition;
+ private int backgroundColor;
+ private int row;
+
+ public CueBuilder() {
+ rolledUpCaptions = new ArrayList<>();
+ captionStringBuilder = new SpannableStringBuilder();
+ reset();
+ }
+
+ public boolean isEmpty() {
+ return !isDefined() || (rolledUpCaptions.isEmpty() && captionStringBuilder.length() == 0);
+ }
+
+ public void reset() {
+ clear();
+
+ defined = false;
+ visible = false;
+ priority = DEFAULT_PRIORITY;
+ relativePositioning = false;
+ verticalAnchor = 0;
+ horizontalAnchor = 0;
+ anchorId = 0;
+ rowCount = MAXIMUM_ROW_COUNT;
+ rowLock = true;
+ justification = JUSTIFICATION_LEFT;
+ windowStyleId = 0;
+ penStyleId = 0;
+ windowFillColor = COLOR_SOLID_BLACK;
+
+ foregroundColor = COLOR_SOLID_WHITE;
+ backgroundColor = COLOR_SOLID_BLACK;
+ }
+
+ public void clear() {
+ rolledUpCaptions.clear();
+ captionStringBuilder.clear();
+ italicsStartPosition = C.POSITION_UNSET;
+ underlineStartPosition = C.POSITION_UNSET;
+ foregroundColorStartPosition = C.POSITION_UNSET;
+ backgroundColorStartPosition = C.POSITION_UNSET;
+ row = 0;
+ }
+
+ public boolean isDefined() {
+ return defined;
+ }
+
+ public void setVisibility(boolean visible) {
+ this.visible = visible;
+ }
+
+ public boolean isVisible() {
+ return visible;
+ }
+
+ public void defineWindow(boolean visible, boolean rowLock, boolean columnLock, int priority,
+ boolean relativePositioning, int verticalAnchor, int horizontalAnchor, int rowCount,
+ int columnCount, int anchorId, int windowStyleId, int penStyleId) {
+ this.defined = true;
+ this.visible = visible;
+ this.rowLock = rowLock;
+ this.priority = priority;
+ this.relativePositioning = relativePositioning;
+ this.verticalAnchor = verticalAnchor;
+ this.horizontalAnchor = horizontalAnchor;
+ this.anchorId = anchorId;
+
+ // Decoders must add one to rowCount to get the desired number of rows.
+ if (this.rowCount != rowCount + 1) {
+ this.rowCount = rowCount + 1;
+
+ // Trim any rolled up captions that are no longer valid, if applicable.
+ while ((rowLock && (rolledUpCaptions.size() >= this.rowCount))
+ || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) {
+ rolledUpCaptions.remove(0);
+ }
+ }
+
+ // TODO: Add support for column lock and count.
+
+ if (windowStyleId != 0 && this.windowStyleId != windowStyleId) {
+ this.windowStyleId = windowStyleId;
+ // windowStyleId is 1-based.
+ int windowStyleIdIndex = windowStyleId - 1;
+ // Note that Border type and border color are the same for all window styles.
+ setWindowAttributes(WINDOW_STYLE_FILL[windowStyleIdIndex], COLOR_TRANSPARENT,
+ WINDOW_STYLE_WORD_WRAP[windowStyleIdIndex], BORDER_AND_EDGE_TYPE_NONE,
+ WINDOW_STYLE_PRINT_DIRECTION[windowStyleIdIndex],
+ WINDOW_STYLE_SCROLL_DIRECTION[windowStyleIdIndex],
+ WINDOW_STYLE_JUSTIFICATION[windowStyleIdIndex]);
+ }
+
+ if (penStyleId != 0 && this.penStyleId != penStyleId) {
+ this.penStyleId = penStyleId;
+ // penStyleId is 1-based.
+ int penStyleIdIndex = penStyleId - 1;
+ // Note that pen size, offset, italics, underline, foreground color, and foreground
+ // opacity are the same for all pen styles.
+ setPenAttributes(0, PEN_OFFSET_NORMAL, PEN_SIZE_STANDARD, false, false,
+ PEN_STYLE_EDGE_TYPE[penStyleIdIndex], PEN_STYLE_FONT_STYLE[penStyleIdIndex]);
+ setPenColor(COLOR_SOLID_WHITE, PEN_STYLE_BACKGROUND[penStyleIdIndex], COLOR_SOLID_BLACK);
+ }
+ }
+
+
+ public void setWindowAttributes(int fillColor, int borderColor, boolean wordWrapToggle,
+ int borderType, int printDirection, int scrollDirection, int justification) {
+ this.windowFillColor = fillColor;
+ // TODO: Add support for border color and types.
+ // TODO: Add support for word wrap.
+ // TODO: Add support for other scroll directions.
+ // TODO: Add support for other print directions.
+ this.justification = justification;
+
+ }
+
+ public void setPenAttributes(int textTag, int offset, int penSize, boolean italicsToggle,
+ boolean underlineToggle, int edgeType, int fontStyle) {
+ // TODO: Add support for text tags.
+ // TODO: Add support for other offsets.
+ // TODO: Add support for other pen sizes.
+
+ if (italicsStartPosition != C.POSITION_UNSET) {
+ if (!italicsToggle) {
+ captionStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition,
+ captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ italicsStartPosition = C.POSITION_UNSET;
+ }
+ } else if (italicsToggle) {
+ italicsStartPosition = captionStringBuilder.length();
+ }
+
+ if (underlineStartPosition != C.POSITION_UNSET) {
+ if (!underlineToggle) {
+ captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition,
+ captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ underlineStartPosition = C.POSITION_UNSET;
+ }
+ } else if (underlineToggle) {
+ underlineStartPosition = captionStringBuilder.length();
+ }
+
+ // TODO: Add support for edge types.
+ // TODO: Add support for other font styles.
+ }
+
+ public void setPenColor(int foregroundColor, int backgroundColor, int edgeColor) {
+ if (foregroundColorStartPosition != C.POSITION_UNSET) {
+ if (this.foregroundColor != foregroundColor) {
+ captionStringBuilder.setSpan(new ForegroundColorSpan(this.foregroundColor),
+ foregroundColorStartPosition, captionStringBuilder.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ if (foregroundColor != COLOR_SOLID_WHITE) {
+ foregroundColorStartPosition = captionStringBuilder.length();
+ this.foregroundColor = foregroundColor;
+ }
+
+ if (backgroundColorStartPosition != C.POSITION_UNSET) {
+ if (this.backgroundColor != backgroundColor) {
+ captionStringBuilder.setSpan(new BackgroundColorSpan(this.backgroundColor),
+ backgroundColorStartPosition, captionStringBuilder.length(),
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+ if (backgroundColor != COLOR_SOLID_BLACK) {
+ backgroundColorStartPosition = captionStringBuilder.length();
+ this.backgroundColor = backgroundColor;
+ }
+
+ // TODO: Add support for edge color.
+ }
+
+ public void setPenLocation(int row, int column) {
+ // TODO: Support moving the pen location with a window properly.
+
+ // Until we support proper pen locations, if we encounter a row that's different from the
+ // previous one, we should append a new line. Otherwise, we'll see strings that should be
+ // on new lines concatenated with the previous, resulting in 2 words being combined, as
+ // well as potentially drawing beyond the width of the window/screen.
+ if (this.row != row) {
+ append('\n');
+ }
+ this.row = row;
+ }
+
+ public void backspace() {
+ int length = captionStringBuilder.length();
+ if (length > 0) {
+ captionStringBuilder.delete(length - 1, length);
+ }
+ }
+
+ public void append(char text) {
+ if (text == '\n') {
+ rolledUpCaptions.add(buildSpannableString());
+ captionStringBuilder.clear();
+
+ if (italicsStartPosition != C.POSITION_UNSET) {
+ italicsStartPosition = 0;
+ }
+ if (underlineStartPosition != C.POSITION_UNSET) {
+ underlineStartPosition = 0;
+ }
+ if (foregroundColorStartPosition != C.POSITION_UNSET) {
+ foregroundColorStartPosition = 0;
+ }
+ if (backgroundColorStartPosition != C.POSITION_UNSET) {
+ backgroundColorStartPosition = 0;
+ }
+
+ while ((rowLock && (rolledUpCaptions.size() >= rowCount))
+ || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) {
+ rolledUpCaptions.remove(0);
+ }
+ } else {
+ captionStringBuilder.append(text);
+ }
+ }
+
+ public SpannableString buildSpannableString() {
+ SpannableStringBuilder spannableStringBuilder =
+ new SpannableStringBuilder(captionStringBuilder);
+ int length = spannableStringBuilder.length();
+
+ if (length > 0) {
+ if (italicsStartPosition != C.POSITION_UNSET) {
+ spannableStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition,
+ length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ if (underlineStartPosition != C.POSITION_UNSET) {
+ spannableStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition,
+ length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ if (foregroundColorStartPosition != C.POSITION_UNSET) {
+ spannableStringBuilder.setSpan(new ForegroundColorSpan(foregroundColor),
+ foregroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+
+ if (backgroundColorStartPosition != C.POSITION_UNSET) {
+ spannableStringBuilder.setSpan(new BackgroundColorSpan(backgroundColor),
+ backgroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ }
+
+ return new SpannableString(spannableStringBuilder);
+ }
+
+ public Cea708Cue build() {
+ if (isEmpty()) {
+ // The cue is empty.
+ return null;
+ }
+
+ SpannableStringBuilder cueString = new SpannableStringBuilder();
+
+ // Add any rolled up captions, separated by new lines.
+ for (int i = 0; i < rolledUpCaptions.size(); i++) {
+ cueString.append(rolledUpCaptions.get(i));
+ cueString.append('\n');
+ }
+ // Add the current line.
+ cueString.append(buildSpannableString());
+
+ // TODO: Add support for right-to-left languages (i.e. where right would correspond to normal
+ // alignment).
+ Alignment alignment;
+ switch (justification) {
+ case JUSTIFICATION_FULL:
+ // TODO: Add support for full justification.
+ case JUSTIFICATION_LEFT:
+ alignment = Alignment.ALIGN_NORMAL;
+ break;
+ case JUSTIFICATION_RIGHT:
+ alignment = Alignment.ALIGN_OPPOSITE;
+ break;
+ case JUSTIFICATION_CENTER:
+ alignment = Alignment.ALIGN_CENTER;
+ break;
+ default:
+ throw new IllegalArgumentException("Unexpected justification value: " + justification);
+ }
+
+ float position;
+ float line;
+ if (relativePositioning) {
+ position = (float) horizontalAnchor / RELATIVE_CUE_SIZE;
+ line = (float) verticalAnchor / RELATIVE_CUE_SIZE;
+ } else {
+ position = (float) horizontalAnchor / HORIZONTAL_SIZE;
+ line = (float) verticalAnchor / VERTICAL_SIZE;
+ }
+ // Apply screen-edge padding to the line and position.
+ position = (position * 0.9f) + 0.05f;
+ line = (line * 0.9f) + 0.05f;
+
+ // anchorId specifies where the anchor should be placed on the caption cue/window. The 9
+ // possible configurations are as follows:
+ // 0-----1-----2
+ // | |
+ // 3 4 5
+ // | |
+ // 6-----7-----8
+ @AnchorType int verticalAnchorType;
+ if (anchorId % 3 == 0) {
+ verticalAnchorType = Cue.ANCHOR_TYPE_START;
+ } else if (anchorId % 3 == 1) {
+ verticalAnchorType = Cue.ANCHOR_TYPE_MIDDLE;
+ } else {
+ verticalAnchorType = Cue.ANCHOR_TYPE_END;
+ }
+ // TODO: Add support for right-to-left languages (i.e. where start is on the right).
+ @AnchorType int horizontalAnchorType;
+ if (anchorId / 3 == 0) {
+ horizontalAnchorType = Cue.ANCHOR_TYPE_START;
+ } else if (anchorId / 3 == 1) {
+ horizontalAnchorType = Cue.ANCHOR_TYPE_MIDDLE;
+ } else {
+ horizontalAnchorType = Cue.ANCHOR_TYPE_END;
+ }
+
+ boolean windowColorSet = (windowFillColor != COLOR_SOLID_BLACK);
+
+ return new Cea708Cue(cueString, alignment, line, Cue.LINE_TYPE_FRACTION, verticalAnchorType,
+ position, horizontalAnchorType, Cue.DIMEN_UNSET, windowColorSet, windowFillColor,
+ priority);
+ }
+
+ public static int getArgbColorFromCeaColor(int red, int green, int blue) {
+ return getArgbColorFromCeaColor(red, green, blue, 0);
+ }
+
+ public static int getArgbColorFromCeaColor(int red, int green, int blue, int opacity) {
+ Assertions.checkIndex(red, 0, 4);
+ Assertions.checkIndex(green, 0, 4);
+ Assertions.checkIndex(blue, 0, 4);
+ Assertions.checkIndex(opacity, 0, 4);
+
+ int alpha;
+ switch (opacity) {
+ case 0:
+ case 1:
+ // Note the value of '1' is actually FLASH, but we don't support that.
+ alpha = 255;
+ break;
+ case 2:
+ alpha = 127;
+ break;
+ case 3:
+ alpha = 0;
+ break;
+ default:
+ alpha = 255;
+ }
+
+ // TODO: Add support for the Alternative Minimum Color List or the full 64 RGB combinations.
+
+ // Return values based on the Minimum Color List
+ return Color.argb(alpha,
+ (red > 1 ? 255 : 0),
+ (green > 1 ? 255 : 0),
+ (blue > 1 ? 255 : 0));
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java
new file mode 100644
index 0000000000..5d63ca8e82
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import java.util.Collections;
+import java.util.List;
+
+/** Initialization data for CEA-708 decoders. */
+public final class Cea708InitializationData {
+
+ /**
+ * Whether the closed caption service is formatted for displays with 16:9 aspect ratio. If false,
+ * the closed caption service is formatted for 4:3 displays.
+ */
+ public final boolean isWideAspectRatio;
+
+ private Cea708InitializationData(List<byte[]> initializationData) {
+ isWideAspectRatio = initializationData.get(0)[0] != 0;
+ }
+
+ /**
+ * Returns an object representation of CEA-708 initialization data
+ *
+ * @param initializationData Binary CEA-708 initialization data.
+ * @return The object representation.
+ */
+ public static Cea708InitializationData fromData(List<byte[]> initializationData) {
+ return new Cea708InitializationData(initializationData);
+ }
+
+ /**
+ * Builds binary CEA-708 initialization data.
+ *
+ * @param isWideAspectRatio Whether the closed caption service is formatted for displays with 16:9
+ * aspect ratio.
+ * @return Binary CEA-708 initializaton data.
+ */
+ public static List<byte[]> buildData(boolean isWideAspectRatio) {
+ return Collections.singletonList(new byte[] {(byte) (isWideAspectRatio ? 1 : 0)});
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java
new file mode 100644
index 0000000000..42fa915fc5
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java
@@ -0,0 +1,204 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import androidx.annotation.NonNull;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.Format;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleOutputBuffer;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayDeque;
+import java.util.PriorityQueue;
+
+/**
+ * Base class for subtitle parsers for CEA captions.
+ */
+/* package */ abstract class CeaDecoder implements SubtitleDecoder {
+
+ private static final int NUM_INPUT_BUFFERS = 10;
+ private static final int NUM_OUTPUT_BUFFERS = 2;
+
+ private final ArrayDeque<CeaInputBuffer> availableInputBuffers;
+ private final ArrayDeque<SubtitleOutputBuffer> availableOutputBuffers;
+ private final PriorityQueue<CeaInputBuffer> queuedInputBuffers;
+
+ private CeaInputBuffer dequeuedInputBuffer;
+ private long playbackPositionUs;
+ private long queuedInputBufferCount;
+
+ public CeaDecoder() {
+ availableInputBuffers = new ArrayDeque<>();
+ for (int i = 0; i < NUM_INPUT_BUFFERS; i++) {
+ availableInputBuffers.add(new CeaInputBuffer());
+ }
+ availableOutputBuffers = new ArrayDeque<>();
+ for (int i = 0; i < NUM_OUTPUT_BUFFERS; i++) {
+ availableOutputBuffers.add(new CeaOutputBuffer());
+ }
+ queuedInputBuffers = new PriorityQueue<>();
+ }
+
+ @Override
+ public abstract String getName();
+
+ @Override
+ public void setPositionUs(long positionUs) {
+ playbackPositionUs = positionUs;
+ }
+
+ @Override
+ public SubtitleInputBuffer dequeueInputBuffer() throws SubtitleDecoderException {
+ Assertions.checkState(dequeuedInputBuffer == null);
+ if (availableInputBuffers.isEmpty()) {
+ return null;
+ }
+ dequeuedInputBuffer = availableInputBuffers.pollFirst();
+ return dequeuedInputBuffer;
+ }
+
+ @Override
+ public void queueInputBuffer(SubtitleInputBuffer inputBuffer) throws SubtitleDecoderException {
+ Assertions.checkArgument(inputBuffer == dequeuedInputBuffer);
+ if (inputBuffer.isDecodeOnly()) {
+ // We can drop this buffer early (i.e. before it would be decoded) as the CEA formats allow
+ // for decoding to begin mid-stream.
+ releaseInputBuffer(dequeuedInputBuffer);
+ } else {
+ dequeuedInputBuffer.queuedInputBufferCount = queuedInputBufferCount++;
+ queuedInputBuffers.add(dequeuedInputBuffer);
+ }
+ dequeuedInputBuffer = null;
+ }
+
+ @Override
+ public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException {
+ if (availableOutputBuffers.isEmpty()) {
+ return null;
+ }
+ // iterate through all available input buffers whose timestamps are less than or equal
+ // to the current playback position; processing input buffers for future content should
+ // be deferred until they would be applicable
+ while (!queuedInputBuffers.isEmpty()
+ && queuedInputBuffers.peek().timeUs <= playbackPositionUs) {
+ CeaInputBuffer inputBuffer = queuedInputBuffers.poll();
+
+ // If the input buffer indicates we've reached the end of the stream, we can
+ // return immediately with an output buffer propagating that
+ if (inputBuffer.isEndOfStream()) {
+ SubtitleOutputBuffer outputBuffer = availableOutputBuffers.pollFirst();
+ outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM);
+ releaseInputBuffer(inputBuffer);
+ return outputBuffer;
+ }
+
+ decode(inputBuffer);
+
+ // check if we have any caption updates to report
+ if (isNewSubtitleDataAvailable()) {
+ // Even if the subtitle is decode-only; we need to generate it to consume the data so it
+ // isn't accidentally prepended to the next subtitle
+ Subtitle subtitle = createSubtitle();
+ if (!inputBuffer.isDecodeOnly()) {
+ SubtitleOutputBuffer outputBuffer = availableOutputBuffers.pollFirst();
+ outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE);
+ releaseInputBuffer(inputBuffer);
+ return outputBuffer;
+ }
+ }
+
+ releaseInputBuffer(inputBuffer);
+ }
+
+ return null;
+ }
+
+ private void releaseInputBuffer(CeaInputBuffer inputBuffer) {
+ inputBuffer.clear();
+ availableInputBuffers.add(inputBuffer);
+ }
+
+ protected void releaseOutputBuffer(SubtitleOutputBuffer outputBuffer) {
+ outputBuffer.clear();
+ availableOutputBuffers.add(outputBuffer);
+ }
+
+ @Override
+ public void flush() {
+ queuedInputBufferCount = 0;
+ playbackPositionUs = 0;
+ while (!queuedInputBuffers.isEmpty()) {
+ releaseInputBuffer(queuedInputBuffers.poll());
+ }
+ if (dequeuedInputBuffer != null) {
+ releaseInputBuffer(dequeuedInputBuffer);
+ dequeuedInputBuffer = null;
+ }
+ }
+
+ @Override
+ public void release() {
+ // Do nothing
+ }
+
+ /**
+ * Returns whether there is data available to create a new {@link Subtitle}.
+ */
+ protected abstract boolean isNewSubtitleDataAvailable();
+
+ /**
+ * Creates a {@link Subtitle} from the available data.
+ */
+ protected abstract Subtitle createSubtitle();
+
+ /**
+ * Filters and processes the raw data, providing {@link Subtitle}s via {@link #createSubtitle()}
+ * when sufficient data has been processed.
+ */
+ protected abstract void decode(SubtitleInputBuffer inputBuffer);
+
+ private static final class CeaInputBuffer extends SubtitleInputBuffer
+ implements Comparable<CeaInputBuffer> {
+
+ private long queuedInputBufferCount;
+
+ @Override
+ public int compareTo(@NonNull CeaInputBuffer other) {
+ if (isEndOfStream() != other.isEndOfStream()) {
+ return isEndOfStream() ? 1 : -1;
+ }
+ long delta = timeUs - other.timeUs;
+ if (delta == 0) {
+ delta = queuedInputBufferCount - other.queuedInputBufferCount;
+ if (delta == 0) {
+ return 0;
+ }
+ }
+ return delta > 0 ? 1 : -1;
+ }
+ }
+
+ private final class CeaOutputBuffer extends SubtitleOutputBuffer {
+
+ @Override
+ public final void release() {
+ releaseOutputBuffer(this);
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java
new file mode 100644
index 0000000000..f4649c4c4b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java
@@ -0,0 +1,60 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A representation of a CEA subtitle.
+ */
+/* package */ final class CeaSubtitle implements Subtitle {
+
+ private final List<Cue> cues;
+
+ /**
+ * @param cues The subtitle cues.
+ */
+ public CeaSubtitle(List<Cue> cues) {
+ this.cues = cues;
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return timeUs < 0 ? 0 : C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ Assertions.checkArgument(index == 0);
+ return 0;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return timeUs >= 0 ? cues : Collections.emptyList();
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java
new file mode 100644
index 0000000000..ced169ba17
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+
+/** Utility methods for handling CEA-608/708 messages. Defined in A/53 Part 4:2009. */
+public final class CeaUtil {
+
+ private static final String TAG = "CeaUtil";
+
+ public static final int USER_DATA_IDENTIFIER_GA94 = 0x47413934;
+ public static final int USER_DATA_TYPE_CODE_MPEG_CC = 0x3;
+
+ private static final int PAYLOAD_TYPE_CC = 4;
+ private static final int COUNTRY_CODE = 0xB5;
+ private static final int PROVIDER_CODE_ATSC = 0x31;
+ private static final int PROVIDER_CODE_DIRECTV = 0x2F;
+
+ /**
+ * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608 messages
+ * as samples to all of the provided outputs.
+ *
+ * @param presentationTimeUs The presentation time in microseconds for any samples.
+ * @param seiBuffer The unescaped SEI NAL unit data, excluding the NAL unit start code and type.
+ * @param outputs The outputs to which any samples should be written.
+ */
+ public static void consume(long presentationTimeUs, ParsableByteArray seiBuffer,
+ TrackOutput[] outputs) {
+ while (seiBuffer.bytesLeft() > 1 /* last byte will be rbsp_trailing_bits */) {
+ int payloadType = readNon255TerminatedValue(seiBuffer);
+ int payloadSize = readNon255TerminatedValue(seiBuffer);
+ int nextPayloadPosition = seiBuffer.getPosition() + payloadSize;
+ // Process the payload.
+ if (payloadSize == -1 || payloadSize > seiBuffer.bytesLeft()) {
+ // This might occur if we're trying to read an encrypted SEI NAL unit.
+ Log.w(TAG, "Skipping remainder of malformed SEI NAL unit.");
+ nextPayloadPosition = seiBuffer.limit();
+ } else if (payloadType == PAYLOAD_TYPE_CC && payloadSize >= 8) {
+ int countryCode = seiBuffer.readUnsignedByte();
+ int providerCode = seiBuffer.readUnsignedShort();
+ int userIdentifier = 0;
+ if (providerCode == PROVIDER_CODE_ATSC) {
+ userIdentifier = seiBuffer.readInt();
+ }
+ int userDataTypeCode = seiBuffer.readUnsignedByte();
+ if (providerCode == PROVIDER_CODE_DIRECTV) {
+ seiBuffer.skipBytes(1); // user_data_length.
+ }
+ boolean messageIsSupportedCeaCaption =
+ countryCode == COUNTRY_CODE
+ && (providerCode == PROVIDER_CODE_ATSC || providerCode == PROVIDER_CODE_DIRECTV)
+ && userDataTypeCode == USER_DATA_TYPE_CODE_MPEG_CC;
+ if (providerCode == PROVIDER_CODE_ATSC) {
+ messageIsSupportedCeaCaption &= userIdentifier == USER_DATA_IDENTIFIER_GA94;
+ }
+ if (messageIsSupportedCeaCaption) {
+ consumeCcData(presentationTimeUs, seiBuffer, outputs);
+ }
+ }
+ seiBuffer.setPosition(nextPayloadPosition);
+ }
+ }
+
+ /**
+ * Consumes caption data (cc_data), writing the content as samples to all of the provided outputs.
+ *
+ * @param presentationTimeUs The presentation time in microseconds for any samples.
+ * @param ccDataBuffer The buffer containing the caption data.
+ * @param outputs The outputs to which any samples should be written.
+ */
+ public static void consumeCcData(
+ long presentationTimeUs, ParsableByteArray ccDataBuffer, TrackOutput[] outputs) {
+ // First byte contains: reserved (1), process_cc_data_flag (1), zero_bit (1), cc_count (5).
+ int firstByte = ccDataBuffer.readUnsignedByte();
+ boolean processCcDataFlag = (firstByte & 0x40) != 0;
+ if (!processCcDataFlag) {
+ // No need to process.
+ return;
+ }
+ int ccCount = firstByte & 0x1F;
+ ccDataBuffer.skipBytes(1); // Ignore em_data
+ // Each data packet consists of 24 bits: marker bits (5) + cc_valid (1) + cc_type (2)
+ // + cc_data_1 (8) + cc_data_2 (8).
+ int sampleLength = ccCount * 3;
+ int sampleStartPosition = ccDataBuffer.getPosition();
+ for (TrackOutput output : outputs) {
+ ccDataBuffer.setPosition(sampleStartPosition);
+ output.sampleData(ccDataBuffer, sampleLength);
+ output.sampleMetadata(
+ presentationTimeUs,
+ C.BUFFER_FLAG_KEY_FRAME,
+ sampleLength,
+ /* offset= */ 0,
+ /* encryptionData= */ null);
+ }
+ }
+
+ /**
+ * Reads a value from the provided buffer consisting of zero or more 0xFF bytes followed by a
+ * terminating byte not equal to 0xFF. The returned value is ((0xFF * N) + T), where N is the
+ * number of 0xFF bytes and T is the value of the terminating byte.
+ *
+ * @param buffer The buffer from which to read the value.
+ * @return The read value, or -1 if the end of the buffer is reached before a value is read.
+ */
+ private static int readNon255TerminatedValue(ParsableByteArray buffer) {
+ int b;
+ int value = 0;
+ do {
+ if (buffer.bytesLeft() == 0) {
+ return -1;
+ }
+ b = buffer.readUnsignedByte();
+ value += b;
+ } while (b == 0xFF);
+ return value;
+ }
+
+ private CeaUtil() {}
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java
new file mode 100644
index 0000000000..e80d06586a
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java
new file mode 100644
index 0000000000..063872ae2e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import java.util.List;
+
+/** A {@link SimpleSubtitleDecoder} for DVB subtitles. */
+public final class DvbDecoder extends SimpleSubtitleDecoder {
+
+ private final DvbParser parser;
+
+ /**
+ * @param initializationData The initialization data for the decoder. The initialization data
+ * must consist of a single byte array containing 5 bytes: flag_pes_stripped (1),
+ * composition_page (2), ancillary_page (2).
+ */
+ public DvbDecoder(List<byte[]> initializationData) {
+ super("DvbDecoder");
+ ParsableByteArray data = new ParsableByteArray(initializationData.get(0));
+ int subtitleCompositionPage = data.readUnsignedShort();
+ int subtitleAncillaryPage = data.readUnsignedShort();
+ parser = new DvbParser(subtitleCompositionPage, subtitleAncillaryPage);
+ }
+
+ @Override
+ protected Subtitle decode(byte[] data, int length, boolean reset) {
+ if (reset) {
+ parser.reset();
+ }
+ return new DvbSubtitle(parser.decode(data, length));
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java
new file mode 100644
index 0000000000..839c206ad7
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java
@@ -0,0 +1,1059 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffXfermode;
+import android.util.SparseArray;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/**
+ * Parses {@link Cue}s from a DVB subtitle bitstream.
+ */
+/* package */ final class DvbParser {
+
+ private static final String TAG = "DvbParser";
+
+ // Segment types, as defined by ETSI EN 300 743 Table 2
+ private static final int SEGMENT_TYPE_PAGE_COMPOSITION = 0x10;
+ private static final int SEGMENT_TYPE_REGION_COMPOSITION = 0x11;
+ private static final int SEGMENT_TYPE_CLUT_DEFINITION = 0x12;
+ private static final int SEGMENT_TYPE_OBJECT_DATA = 0x13;
+ private static final int SEGMENT_TYPE_DISPLAY_DEFINITION = 0x14;
+
+ // Page states, as defined by ETSI EN 300 743 Table 3
+ private static final int PAGE_STATE_NORMAL = 0; // Update. Only changed elements.
+ // private static final int PAGE_STATE_ACQUISITION = 1; // Refresh. All elements.
+ // private static final int PAGE_STATE_CHANGE = 2; // New. All elements.
+
+ // Region depths, as defined by ETSI EN 300 743 Table 5
+ // private static final int REGION_DEPTH_2_BIT = 1;
+ private static final int REGION_DEPTH_4_BIT = 2;
+ private static final int REGION_DEPTH_8_BIT = 3;
+
+ // Object codings, as defined by ETSI EN 300 743 Table 8
+ private static final int OBJECT_CODING_PIXELS = 0;
+ private static final int OBJECT_CODING_STRING = 1;
+
+ // Pixel-data types, as defined by ETSI EN 300 743 Table 9
+ private static final int DATA_TYPE_2BP_CODE_STRING = 0x10;
+ private static final int DATA_TYPE_4BP_CODE_STRING = 0x11;
+ private static final int DATA_TYPE_8BP_CODE_STRING = 0x12;
+ private static final int DATA_TYPE_24_TABLE_DATA = 0x20;
+ private static final int DATA_TYPE_28_TABLE_DATA = 0x21;
+ private static final int DATA_TYPE_48_TABLE_DATA = 0x22;
+ private static final int DATA_TYPE_END_LINE = 0xF0;
+
+ // Clut mapping tables, as defined by ETSI EN 300 743 10.4, 10.5, 10.6
+ private static final byte[] defaultMap2To4 = {
+ (byte) 0x00, (byte) 0x07, (byte) 0x08, (byte) 0x0F};
+ private static final byte[] defaultMap2To8 = {
+ (byte) 0x00, (byte) 0x77, (byte) 0x88, (byte) 0xFF};
+ private static final byte[] defaultMap4To8 = {
+ (byte) 0x00, (byte) 0x11, (byte) 0x22, (byte) 0x33,
+ (byte) 0x44, (byte) 0x55, (byte) 0x66, (byte) 0x77,
+ (byte) 0x88, (byte) 0x99, (byte) 0xAA, (byte) 0xBB,
+ (byte) 0xCC, (byte) 0xDD, (byte) 0xEE, (byte) 0xFF};
+
+ private final Paint defaultPaint;
+ private final Paint fillRegionPaint;
+ private final Canvas canvas;
+ private final DisplayDefinition defaultDisplayDefinition;
+ private final ClutDefinition defaultClutDefinition;
+ private final SubtitleService subtitleService;
+
+ @MonotonicNonNull private Bitmap bitmap;
+
+ /**
+ * Construct an instance for the given subtitle and ancillary page ids.
+ *
+ * @param subtitlePageId The id of the subtitle page carrying the subtitle to be parsed.
+ * @param ancillaryPageId The id of the ancillary page containing additional data.
+ */
+ public DvbParser(int subtitlePageId, int ancillaryPageId) {
+ defaultPaint = new Paint();
+ defaultPaint.setStyle(Paint.Style.FILL_AND_STROKE);
+ defaultPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC));
+ defaultPaint.setPathEffect(null);
+ fillRegionPaint = new Paint();
+ fillRegionPaint.setStyle(Paint.Style.FILL);
+ fillRegionPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER));
+ fillRegionPaint.setPathEffect(null);
+ canvas = new Canvas();
+ defaultDisplayDefinition = new DisplayDefinition(719, 575, 0, 719, 0, 575);
+ defaultClutDefinition = new ClutDefinition(0, generateDefault2BitClutEntries(),
+ generateDefault4BitClutEntries(), generateDefault8BitClutEntries());
+ subtitleService = new SubtitleService(subtitlePageId, ancillaryPageId);
+ }
+
+ /**
+ * Resets the parser.
+ */
+ public void reset() {
+ subtitleService.reset();
+ }
+
+ /**
+ * Decodes a subtitling packet, returning a list of parsed {@link Cue}s.
+ *
+ * @param data The subtitling packet data to decode.
+ * @param limit The limit in {@code data} at which to stop decoding.
+ * @return The parsed {@link Cue}s.
+ */
+ public List<Cue> decode(byte[] data, int limit) {
+ // Parse the input data.
+ ParsableBitArray dataBitArray = new ParsableBitArray(data, limit);
+ while (dataBitArray.bitsLeft() >= 48 // sync_byte (8) + segment header (40)
+ && dataBitArray.readBits(8) == 0x0F) {
+ parseSubtitlingSegment(dataBitArray, subtitleService);
+ }
+
+ @Nullable PageComposition pageComposition = subtitleService.pageComposition;
+ if (pageComposition == null) {
+ return Collections.emptyList();
+ }
+
+ // Update the canvas bitmap if necessary.
+ DisplayDefinition displayDefinition = subtitleService.displayDefinition != null
+ ? subtitleService.displayDefinition : defaultDisplayDefinition;
+ if (bitmap == null || displayDefinition.width + 1 != bitmap.getWidth()
+ || displayDefinition.height + 1 != bitmap.getHeight()) {
+ bitmap = Bitmap.createBitmap(displayDefinition.width + 1, displayDefinition.height + 1,
+ Bitmap.Config.ARGB_8888);
+ canvas.setBitmap(bitmap);
+ }
+
+ // Build the cues.
+ List<Cue> cues = new ArrayList<>();
+ SparseArray<PageRegion> pageRegions = pageComposition.regions;
+ for (int i = 0; i < pageRegions.size(); i++) {
+ // Save clean clipping state.
+ canvas.save();
+ PageRegion pageRegion = pageRegions.valueAt(i);
+ int regionId = pageRegions.keyAt(i);
+ RegionComposition regionComposition = subtitleService.regions.get(regionId);
+
+ // Clip drawing to the current region and display definition window.
+ int baseHorizontalAddress = pageRegion.horizontalAddress
+ + displayDefinition.horizontalPositionMinimum;
+ int baseVerticalAddress = pageRegion.verticalAddress
+ + displayDefinition.verticalPositionMinimum;
+ int clipRight = Math.min(baseHorizontalAddress + regionComposition.width,
+ displayDefinition.horizontalPositionMaximum);
+ int clipBottom = Math.min(baseVerticalAddress + regionComposition.height,
+ displayDefinition.verticalPositionMaximum);
+ canvas.clipRect(baseHorizontalAddress, baseVerticalAddress, clipRight, clipBottom);
+ ClutDefinition clutDefinition = subtitleService.cluts.get(regionComposition.clutId);
+ if (clutDefinition == null) {
+ clutDefinition = subtitleService.ancillaryCluts.get(regionComposition.clutId);
+ if (clutDefinition == null) {
+ clutDefinition = defaultClutDefinition;
+ }
+ }
+
+ SparseArray<RegionObject> regionObjects = regionComposition.regionObjects;
+ for (int j = 0; j < regionObjects.size(); j++) {
+ int objectId = regionObjects.keyAt(j);
+ RegionObject regionObject = regionObjects.valueAt(j);
+ ObjectData objectData = subtitleService.objects.get(objectId);
+ if (objectData == null) {
+ objectData = subtitleService.ancillaryObjects.get(objectId);
+ }
+ if (objectData != null) {
+ @Nullable Paint paint = objectData.nonModifyingColorFlag ? null : defaultPaint;
+ paintPixelDataSubBlocks(objectData, clutDefinition, regionComposition.depth,
+ baseHorizontalAddress + regionObject.horizontalPosition,
+ baseVerticalAddress + regionObject.verticalPosition, paint, canvas);
+ }
+ }
+
+ if (regionComposition.fillFlag) {
+ int color;
+ if (regionComposition.depth == REGION_DEPTH_8_BIT) {
+ color = clutDefinition.clutEntries8Bit[regionComposition.pixelCode8Bit];
+ } else if (regionComposition.depth == REGION_DEPTH_4_BIT) {
+ color = clutDefinition.clutEntries4Bit[regionComposition.pixelCode4Bit];
+ } else {
+ color = clutDefinition.clutEntries2Bit[regionComposition.pixelCode2Bit];
+ }
+ fillRegionPaint.setColor(color);
+ canvas.drawRect(baseHorizontalAddress, baseVerticalAddress,
+ baseHorizontalAddress + regionComposition.width,
+ baseVerticalAddress + regionComposition.height,
+ fillRegionPaint);
+ }
+
+ Bitmap cueBitmap = Bitmap.createBitmap(bitmap, baseHorizontalAddress, baseVerticalAddress,
+ regionComposition.width, regionComposition.height);
+ cues.add(new Cue(cueBitmap, (float) baseHorizontalAddress / displayDefinition.width,
+ Cue.ANCHOR_TYPE_START, (float) baseVerticalAddress / displayDefinition.height,
+ Cue.ANCHOR_TYPE_START, (float) regionComposition.width / displayDefinition.width,
+ (float) regionComposition.height / displayDefinition.height));
+
+ canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
+ // Restore clean clipping state.
+ canvas.restore();
+ }
+
+ return Collections.unmodifiableList(cues);
+ }
+
+ // Static parsing.
+
+ /**
+ * Parses a subtitling segment, as defined by ETSI EN 300 743 7.2
+ * <p>
+ * The {@link SubtitleService} is updated with the parsed segment data.
+ */
+ private static void parseSubtitlingSegment(ParsableBitArray data, SubtitleService service) {
+ int segmentType = data.readBits(8);
+ int pageId = data.readBits(16);
+ int dataFieldLength = data.readBits(16);
+ int dataFieldLimit = data.getBytePosition() + dataFieldLength;
+
+ if ((dataFieldLength * 8) > data.bitsLeft()) {
+ Log.w(TAG, "Data field length exceeds limit");
+ // Skip to the very end.
+ data.skipBits(data.bitsLeft());
+ return;
+ }
+
+ switch (segmentType) {
+ case SEGMENT_TYPE_DISPLAY_DEFINITION:
+ if (pageId == service.subtitlePageId) {
+ service.displayDefinition = parseDisplayDefinition(data);
+ }
+ break;
+ case SEGMENT_TYPE_PAGE_COMPOSITION:
+ if (pageId == service.subtitlePageId) {
+ @Nullable PageComposition current = service.pageComposition;
+ PageComposition pageComposition = parsePageComposition(data, dataFieldLength);
+ if (pageComposition.state != PAGE_STATE_NORMAL) {
+ service.pageComposition = pageComposition;
+ service.regions.clear();
+ service.cluts.clear();
+ service.objects.clear();
+ } else if (current != null && current.version != pageComposition.version) {
+ service.pageComposition = pageComposition;
+ }
+ }
+ break;
+ case SEGMENT_TYPE_REGION_COMPOSITION:
+ @Nullable PageComposition pageComposition = service.pageComposition;
+ if (pageId == service.subtitlePageId && pageComposition != null) {
+ RegionComposition regionComposition = parseRegionComposition(data, dataFieldLength);
+ if (pageComposition.state == PAGE_STATE_NORMAL) {
+ @Nullable
+ RegionComposition existingRegionComposition = service.regions.get(regionComposition.id);
+ if (existingRegionComposition != null) {
+ regionComposition.mergeFrom(existingRegionComposition);
+ }
+ }
+ service.regions.put(regionComposition.id, regionComposition);
+ }
+ break;
+ case SEGMENT_TYPE_CLUT_DEFINITION:
+ if (pageId == service.subtitlePageId) {
+ ClutDefinition clutDefinition = parseClutDefinition(data, dataFieldLength);
+ service.cluts.put(clutDefinition.id, clutDefinition);
+ } else if (pageId == service.ancillaryPageId) {
+ ClutDefinition clutDefinition = parseClutDefinition(data, dataFieldLength);
+ service.ancillaryCluts.put(clutDefinition.id, clutDefinition);
+ }
+ break;
+ case SEGMENT_TYPE_OBJECT_DATA:
+ if (pageId == service.subtitlePageId) {
+ ObjectData objectData = parseObjectData(data);
+ service.objects.put(objectData.id, objectData);
+ } else if (pageId == service.ancillaryPageId) {
+ ObjectData objectData = parseObjectData(data);
+ service.ancillaryObjects.put(objectData.id, objectData);
+ }
+ break;
+ default:
+ // Do nothing.
+ break;
+ }
+
+ // Skip to the next segment.
+ data.skipBytes(dataFieldLimit - data.getBytePosition());
+ }
+
+ /**
+ * Parses a display definition segment, as defined by ETSI EN 300 743 7.2.1.
+ */
+ private static DisplayDefinition parseDisplayDefinition(ParsableBitArray data) {
+ data.skipBits(4); // dds_version_number (4).
+ boolean displayWindowFlag = data.readBit();
+ data.skipBits(3); // Skip reserved.
+ int width = data.readBits(16);
+ int height = data.readBits(16);
+
+ int horizontalPositionMinimum;
+ int horizontalPositionMaximum;
+ int verticalPositionMinimum;
+ int verticalPositionMaximum;
+ if (displayWindowFlag) {
+ horizontalPositionMinimum = data.readBits(16);
+ horizontalPositionMaximum = data.readBits(16);
+ verticalPositionMinimum = data.readBits(16);
+ verticalPositionMaximum = data.readBits(16);
+ } else {
+ horizontalPositionMinimum = 0;
+ horizontalPositionMaximum = width;
+ verticalPositionMinimum = 0;
+ verticalPositionMaximum = height;
+ }
+
+ return new DisplayDefinition(width, height, horizontalPositionMinimum,
+ horizontalPositionMaximum, verticalPositionMinimum, verticalPositionMaximum);
+ }
+
+ /**
+ * Parses a page composition segment, as defined by ETSI EN 300 743 7.2.2.
+ */
+ private static PageComposition parsePageComposition(ParsableBitArray data, int length) {
+ int timeoutSecs = data.readBits(8);
+ int version = data.readBits(4);
+ int state = data.readBits(2);
+ data.skipBits(2);
+ int remainingLength = length - 2;
+
+ SparseArray<PageRegion> regions = new SparseArray<>();
+ while (remainingLength > 0) {
+ int regionId = data.readBits(8);
+ data.skipBits(8); // Skip reserved.
+ int regionHorizontalAddress = data.readBits(16);
+ int regionVerticalAddress = data.readBits(16);
+ remainingLength -= 6;
+ regions.put(regionId, new PageRegion(regionHorizontalAddress, regionVerticalAddress));
+ }
+
+ return new PageComposition(timeoutSecs, version, state, regions);
+ }
+
+ /**
+ * Parses a region composition segment, as defined by ETSI EN 300 743 7.2.3.
+ */
+ private static RegionComposition parseRegionComposition(ParsableBitArray data, int length) {
+ int id = data.readBits(8);
+ data.skipBits(4); // Skip region_version_number
+ boolean fillFlag = data.readBit();
+ data.skipBits(3); // Skip reserved.
+ int width = data.readBits(16);
+ int height = data.readBits(16);
+ int levelOfCompatibility = data.readBits(3);
+ int depth = data.readBits(3);
+ data.skipBits(2); // Skip reserved.
+ int clutId = data.readBits(8);
+ int pixelCode8Bit = data.readBits(8);
+ int pixelCode4Bit = data.readBits(4);
+ int pixelCode2Bit = data.readBits(2);
+ data.skipBits(2); // Skip reserved
+ int remainingLength = length - 10;
+
+ SparseArray<RegionObject> regionObjects = new SparseArray<>();
+ while (remainingLength > 0) {
+ int objectId = data.readBits(16);
+ int objectType = data.readBits(2);
+ int objectProvider = data.readBits(2);
+ int objectHorizontalPosition = data.readBits(12);
+ data.skipBits(4); // Skip reserved.
+ int objectVerticalPosition = data.readBits(12);
+ remainingLength -= 6;
+
+ int foregroundPixelCode = 0;
+ int backgroundPixelCode = 0;
+ if (objectType == 0x01 || objectType == 0x02) { // Only seems to affect to char subtitles.
+ foregroundPixelCode = data.readBits(8);
+ backgroundPixelCode = data.readBits(8);
+ remainingLength -= 2;
+ }
+
+ regionObjects.put(objectId, new RegionObject(objectType, objectProvider,
+ objectHorizontalPosition, objectVerticalPosition, foregroundPixelCode,
+ backgroundPixelCode));
+ }
+
+ return new RegionComposition(id, fillFlag, width, height, levelOfCompatibility, depth, clutId,
+ pixelCode8Bit, pixelCode4Bit, pixelCode2Bit, regionObjects);
+ }
+
+ /**
+ * Parses a CLUT definition segment, as defined by ETSI EN 300 743 7.2.4.
+ */
+ private static ClutDefinition parseClutDefinition(ParsableBitArray data, int length) {
+ int clutId = data.readBits(8);
+ data.skipBits(8); // Skip clut_version_number (4), reserved (4)
+ int remainingLength = length - 2;
+
+ int[] clutEntries2Bit = generateDefault2BitClutEntries();
+ int[] clutEntries4Bit = generateDefault4BitClutEntries();
+ int[] clutEntries8Bit = generateDefault8BitClutEntries();
+
+ while (remainingLength > 0) {
+ int entryId = data.readBits(8);
+ int entryFlags = data.readBits(8);
+ remainingLength -= 2;
+
+ int[] clutEntries;
+ if ((entryFlags & 0x80) != 0) {
+ clutEntries = clutEntries2Bit;
+ } else if ((entryFlags & 0x40) != 0) {
+ clutEntries = clutEntries4Bit;
+ } else {
+ clutEntries = clutEntries8Bit;
+ }
+
+ int y;
+ int cr;
+ int cb;
+ int t;
+ if ((entryFlags & 0x01) != 0) {
+ y = data.readBits(8);
+ cr = data.readBits(8);
+ cb = data.readBits(8);
+ t = data.readBits(8);
+ remainingLength -= 4;
+ } else {
+ y = data.readBits(6) << 2;
+ cr = data.readBits(4) << 4;
+ cb = data.readBits(4) << 4;
+ t = data.readBits(2) << 6;
+ remainingLength -= 2;
+ }
+
+ if (y == 0x00) {
+ cr = 0x00;
+ cb = 0x00;
+ t = 0xFF;
+ }
+
+ int a = (byte) (0xFF - (t & 0xFF));
+ int r = (int) (y + (1.40200 * (cr - 128)));
+ int g = (int) (y - (0.34414 * (cb - 128)) - (0.71414 * (cr - 128)));
+ int b = (int) (y + (1.77200 * (cb - 128)));
+ clutEntries[entryId] = getColor(a, Util.constrainValue(r, 0, 255),
+ Util.constrainValue(g, 0, 255), Util.constrainValue(b, 0, 255));
+ }
+
+ return new ClutDefinition(clutId, clutEntries2Bit, clutEntries4Bit, clutEntries8Bit);
+ }
+
+ /**
+ * Parses an object data segment, as defined by ETSI EN 300 743 7.2.5.
+ *
+ * @return The parsed object data.
+ */
+ private static ObjectData parseObjectData(ParsableBitArray data) {
+ int objectId = data.readBits(16);
+ data.skipBits(4); // Skip object_version_number
+ int objectCodingMethod = data.readBits(2);
+ boolean nonModifyingColorFlag = data.readBit();
+ data.skipBits(1); // Skip reserved.
+
+ @Nullable byte[] topFieldData = null;
+ @Nullable byte[] bottomFieldData = null;
+
+ if (objectCodingMethod == OBJECT_CODING_STRING) {
+ int numberOfCodes = data.readBits(8);
+ // TODO: Parse and use character_codes.
+ data.skipBits(numberOfCodes * 16); // Skip character_codes.
+ } else if (objectCodingMethod == OBJECT_CODING_PIXELS) {
+ int topFieldDataLength = data.readBits(16);
+ int bottomFieldDataLength = data.readBits(16);
+ if (topFieldDataLength > 0) {
+ topFieldData = new byte[topFieldDataLength];
+ data.readBytes(topFieldData, 0, topFieldDataLength);
+ }
+ if (bottomFieldDataLength > 0) {
+ bottomFieldData = new byte[bottomFieldDataLength];
+ data.readBytes(bottomFieldData, 0, bottomFieldDataLength);
+ } else {
+ bottomFieldData = topFieldData;
+ }
+ }
+
+ return new ObjectData(objectId, nonModifyingColorFlag, topFieldData, bottomFieldData);
+ }
+
+ private static int[] generateDefault2BitClutEntries() {
+ int[] entries = new int[4];
+ entries[0] = 0x00000000;
+ entries[1] = 0xFFFFFFFF;
+ entries[2] = 0xFF000000;
+ entries[3] = 0xFF7F7F7F;
+ return entries;
+ }
+
+ private static int[] generateDefault4BitClutEntries() {
+ int[] entries = new int[16];
+ entries[0] = 0x00000000;
+ for (int i = 1; i < entries.length; i++) {
+ if (i < 8) {
+ entries[i] = getColor(
+ 0xFF,
+ ((i & 0x01) != 0 ? 0xFF : 0x00),
+ ((i & 0x02) != 0 ? 0xFF : 0x00),
+ ((i & 0x04) != 0 ? 0xFF : 0x00));
+ } else {
+ entries[i] = getColor(
+ 0xFF,
+ ((i & 0x01) != 0 ? 0x7F : 0x00),
+ ((i & 0x02) != 0 ? 0x7F : 0x00),
+ ((i & 0x04) != 0 ? 0x7F : 0x00));
+ }
+ }
+ return entries;
+ }
+
+ private static int[] generateDefault8BitClutEntries() {
+ int[] entries = new int[256];
+ entries[0] = 0x00000000;
+ for (int i = 0; i < entries.length; i++) {
+ if (i < 8) {
+ entries[i] = getColor(
+ 0x3F,
+ ((i & 0x01) != 0 ? 0xFF : 0x00),
+ ((i & 0x02) != 0 ? 0xFF : 0x00),
+ ((i & 0x04) != 0 ? 0xFF : 0x00));
+ } else {
+ switch (i & 0x88) {
+ case 0x00:
+ entries[i] = getColor(
+ 0xFF,
+ (((i & 0x01) != 0 ? 0x55 : 0x00) + ((i & 0x10) != 0 ? 0xAA : 0x00)),
+ (((i & 0x02) != 0 ? 0x55 : 0x00) + ((i & 0x20) != 0 ? 0xAA : 0x00)),
+ (((i & 0x04) != 0 ? 0x55 : 0x00) + ((i & 0x40) != 0 ? 0xAA : 0x00)));
+ break;
+ case 0x08:
+ entries[i] = getColor(
+ 0x7F,
+ (((i & 0x01) != 0 ? 0x55 : 0x00) + ((i & 0x10) != 0 ? 0xAA : 0x00)),
+ (((i & 0x02) != 0 ? 0x55 : 0x00) + ((i & 0x20) != 0 ? 0xAA : 0x00)),
+ (((i & 0x04) != 0 ? 0x55 : 0x00) + ((i & 0x40) != 0 ? 0xAA : 0x00)));
+ break;
+ case 0x80:
+ entries[i] = getColor(
+ 0xFF,
+ (127 + ((i & 0x01) != 0 ? 0x2B : 0x00) + ((i & 0x10) != 0 ? 0x55 : 0x00)),
+ (127 + ((i & 0x02) != 0 ? 0x2B : 0x00) + ((i & 0x20) != 0 ? 0x55 : 0x00)),
+ (127 + ((i & 0x04) != 0 ? 0x2B : 0x00) + ((i & 0x40) != 0 ? 0x55 : 0x00)));
+ break;
+ case 0x88:
+ entries[i] = getColor(
+ 0xFF,
+ (((i & 0x01) != 0 ? 0x2B : 0x00) + ((i & 0x10) != 0 ? 0x55 : 0x00)),
+ (((i & 0x02) != 0 ? 0x2B : 0x00) + ((i & 0x20) != 0 ? 0x55 : 0x00)),
+ (((i & 0x04) != 0 ? 0x2B : 0x00) + ((i & 0x40) != 0 ? 0x55 : 0x00)));
+ break;
+ }
+ }
+ }
+ return entries;
+ }
+
+ private static int getColor(int a, int r, int g, int b) {
+ return (a << 24) | (r << 16) | (g << 8) | b;
+ }
+
+ // Static drawing.
+
+ /** Draws a pixel data sub-block, as defined by ETSI EN 300 743 7.2.5.1, into a canvas. */
+ private static void paintPixelDataSubBlocks(
+ ObjectData objectData,
+ ClutDefinition clutDefinition,
+ int regionDepth,
+ int horizontalAddress,
+ int verticalAddress,
+ @Nullable Paint paint,
+ Canvas canvas) {
+ int[] clutEntries;
+ if (regionDepth == REGION_DEPTH_8_BIT) {
+ clutEntries = clutDefinition.clutEntries8Bit;
+ } else if (regionDepth == REGION_DEPTH_4_BIT) {
+ clutEntries = clutDefinition.clutEntries4Bit;
+ } else {
+ clutEntries = clutDefinition.clutEntries2Bit;
+ }
+ paintPixelDataSubBlock(objectData.topFieldData, clutEntries, regionDepth, horizontalAddress,
+ verticalAddress, paint, canvas);
+ paintPixelDataSubBlock(objectData.bottomFieldData, clutEntries, regionDepth, horizontalAddress,
+ verticalAddress + 1, paint, canvas);
+ }
+
+ /** Draws a pixel data sub-block, as defined by ETSI EN 300 743 7.2.5.1, into a canvas. */
+ private static void paintPixelDataSubBlock(
+ byte[] pixelData,
+ int[] clutEntries,
+ int regionDepth,
+ int horizontalAddress,
+ int verticalAddress,
+ @Nullable Paint paint,
+ Canvas canvas) {
+ ParsableBitArray data = new ParsableBitArray(pixelData);
+ int column = horizontalAddress;
+ int line = verticalAddress;
+ @Nullable byte[] clutMapTable2To4 = null;
+ @Nullable byte[] clutMapTable2To8 = null;
+ @Nullable byte[] clutMapTable4To8 = null;
+
+ while (data.bitsLeft() != 0) {
+ int dataType = data.readBits(8);
+ switch (dataType) {
+ case DATA_TYPE_2BP_CODE_STRING:
+ @Nullable byte[] clutMapTable2ToX;
+ if (regionDepth == REGION_DEPTH_8_BIT) {
+ clutMapTable2ToX = clutMapTable2To8 == null ? defaultMap2To8 : clutMapTable2To8;
+ } else if (regionDepth == REGION_DEPTH_4_BIT) {
+ clutMapTable2ToX = clutMapTable2To4 == null ? defaultMap2To4 : clutMapTable2To4;
+ } else {
+ clutMapTable2ToX = null;
+ }
+ column = paint2BitPixelCodeString(data, clutEntries, clutMapTable2ToX, column, line,
+ paint, canvas);
+ data.byteAlign();
+ break;
+ case DATA_TYPE_4BP_CODE_STRING:
+ @Nullable byte[] clutMapTable4ToX;
+ if (regionDepth == REGION_DEPTH_8_BIT) {
+ clutMapTable4ToX = clutMapTable4To8 == null ? defaultMap4To8 : clutMapTable4To8;
+ } else {
+ clutMapTable4ToX = null;
+ }
+ column = paint4BitPixelCodeString(data, clutEntries, clutMapTable4ToX, column, line,
+ paint, canvas);
+ data.byteAlign();
+ break;
+ case DATA_TYPE_8BP_CODE_STRING:
+ column =
+ paint8BitPixelCodeString(
+ data, clutEntries, /* clutMapTable= */ null, column, line, paint, canvas);
+ break;
+ case DATA_TYPE_24_TABLE_DATA:
+ clutMapTable2To4 = buildClutMapTable(4, 4, data);
+ break;
+ case DATA_TYPE_28_TABLE_DATA:
+ clutMapTable2To8 = buildClutMapTable(4, 8, data);
+ break;
+ case DATA_TYPE_48_TABLE_DATA:
+ clutMapTable4To8 = buildClutMapTable(16, 8, data);
+ break;
+ case DATA_TYPE_END_LINE:
+ column = horizontalAddress;
+ line += 2;
+ break;
+ default:
+ // Do nothing.
+ break;
+ }
+ }
+ }
+
+ /** Paint a 2-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */
+ private static int paint2BitPixelCodeString(
+ ParsableBitArray data,
+ int[] clutEntries,
+ @Nullable byte[] clutMapTable,
+ int column,
+ int line,
+ @Nullable Paint paint,
+ Canvas canvas) {
+ boolean endOfPixelCodeString = false;
+ do {
+ int runLength = 0;
+ int clutIndex = 0;
+ int peek = data.readBits(2);
+ if (peek != 0x00) {
+ runLength = 1;
+ clutIndex = peek;
+ } else if (data.readBit()) {
+ runLength = 3 + data.readBits(3);
+ clutIndex = data.readBits(2);
+ } else if (data.readBit()) {
+ runLength = 1;
+ } else {
+ switch (data.readBits(2)) {
+ case 0x00:
+ endOfPixelCodeString = true;
+ break;
+ case 0x01:
+ runLength = 2;
+ break;
+ case 0x02:
+ runLength = 12 + data.readBits(4);
+ clutIndex = data.readBits(2);
+ break;
+ case 0x03:
+ runLength = 29 + data.readBits(8);
+ clutIndex = data.readBits(2);
+ break;
+ }
+ }
+
+ if (runLength != 0 && paint != null) {
+ paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]);
+ canvas.drawRect(column, line, column + runLength, line + 1, paint);
+ }
+
+ column += runLength;
+ } while (!endOfPixelCodeString);
+
+ return column;
+ }
+
+ /** Paint a 4-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */
+ private static int paint4BitPixelCodeString(
+ ParsableBitArray data,
+ int[] clutEntries,
+ @Nullable byte[] clutMapTable,
+ int column,
+ int line,
+ @Nullable Paint paint,
+ Canvas canvas) {
+ boolean endOfPixelCodeString = false;
+ do {
+ int runLength = 0;
+ int clutIndex = 0;
+ int peek = data.readBits(4);
+ if (peek != 0x00) {
+ runLength = 1;
+ clutIndex = peek;
+ } else if (!data.readBit()) {
+ peek = data.readBits(3);
+ if (peek != 0x00) {
+ runLength = 2 + peek;
+ clutIndex = 0x00;
+ } else {
+ endOfPixelCodeString = true;
+ }
+ } else if (!data.readBit()) {
+ runLength = 4 + data.readBits(2);
+ clutIndex = data.readBits(4);
+ } else {
+ switch (data.readBits(2)) {
+ case 0x00:
+ runLength = 1;
+ break;
+ case 0x01:
+ runLength = 2;
+ break;
+ case 0x02:
+ runLength = 9 + data.readBits(4);
+ clutIndex = data.readBits(4);
+ break;
+ case 0x03:
+ runLength = 25 + data.readBits(8);
+ clutIndex = data.readBits(4);
+ break;
+ }
+ }
+
+ if (runLength != 0 && paint != null) {
+ paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]);
+ canvas.drawRect(column, line, column + runLength, line + 1, paint);
+ }
+
+ column += runLength;
+ } while (!endOfPixelCodeString);
+
+ return column;
+ }
+
+ /** Paint an 8-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */
+ private static int paint8BitPixelCodeString(
+ ParsableBitArray data,
+ int[] clutEntries,
+ @Nullable byte[] clutMapTable,
+ int column,
+ int line,
+ @Nullable Paint paint,
+ Canvas canvas) {
+ boolean endOfPixelCodeString = false;
+ do {
+ int runLength = 0;
+ int clutIndex = 0;
+ int peek = data.readBits(8);
+ if (peek != 0x00) {
+ runLength = 1;
+ clutIndex = peek;
+ } else {
+ if (!data.readBit()) {
+ peek = data.readBits(7);
+ if (peek != 0x00) {
+ runLength = peek;
+ clutIndex = 0x00;
+ } else {
+ endOfPixelCodeString = true;
+ }
+ } else {
+ runLength = data.readBits(7);
+ clutIndex = data.readBits(8);
+ }
+ }
+
+ if (runLength != 0 && paint != null) {
+ paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]);
+ canvas.drawRect(column, line, column + runLength, line + 1, paint);
+ }
+ column += runLength;
+ } while (!endOfPixelCodeString);
+
+ return column;
+ }
+
+ private static byte[] buildClutMapTable(int length, int bitsPerEntry, ParsableBitArray data) {
+ byte[] clutMapTable = new byte[length];
+ for (int i = 0; i < length; i++) {
+ clutMapTable[i] = (byte) data.readBits(bitsPerEntry);
+ }
+ return clutMapTable;
+ }
+
+ // Private inner classes.
+
+ /**
+ * The subtitle service definition.
+ */
+ private static final class SubtitleService {
+
+ public final int subtitlePageId;
+ public final int ancillaryPageId;
+
+ public final SparseArray<RegionComposition> regions;
+ public final SparseArray<ClutDefinition> cluts;
+ public final SparseArray<ObjectData> objects;
+ public final SparseArray<ClutDefinition> ancillaryCluts;
+ public final SparseArray<ObjectData> ancillaryObjects;
+
+ @Nullable public DisplayDefinition displayDefinition;
+ @Nullable public PageComposition pageComposition;
+
+ public SubtitleService(int subtitlePageId, int ancillaryPageId) {
+ this.subtitlePageId = subtitlePageId;
+ this.ancillaryPageId = ancillaryPageId;
+ regions = new SparseArray<>();
+ cluts = new SparseArray<>();
+ objects = new SparseArray<>();
+ ancillaryCluts = new SparseArray<>();
+ ancillaryObjects = new SparseArray<>();
+ }
+
+ public void reset() {
+ regions.clear();
+ cluts.clear();
+ objects.clear();
+ ancillaryCluts.clear();
+ ancillaryObjects.clear();
+ displayDefinition = null;
+ pageComposition = null;
+ }
+
+ }
+
+ /**
+ * Contains the geometry and active area of the subtitle service.
+ * <p>
+ * See ETSI EN 300 743 7.2.1
+ */
+ private static final class DisplayDefinition {
+
+ public final int width;
+ public final int height;
+
+ public final int horizontalPositionMinimum;
+ public final int horizontalPositionMaximum;
+ public final int verticalPositionMinimum;
+ public final int verticalPositionMaximum;
+
+ public DisplayDefinition(int width, int height, int horizontalPositionMinimum,
+ int horizontalPositionMaximum, int verticalPositionMinimum, int verticalPositionMaximum) {
+ this.width = width;
+ this.height = height;
+ this.horizontalPositionMinimum = horizontalPositionMinimum;
+ this.horizontalPositionMaximum = horizontalPositionMaximum;
+ this.verticalPositionMinimum = verticalPositionMinimum;
+ this.verticalPositionMaximum = verticalPositionMaximum;
+ }
+
+ }
+
+ /**
+ * The page is the definition and arrangement of regions in the screen.
+ * <p>
+ * See ETSI EN 300 743 7.2.2
+ */
+ private static final class PageComposition {
+
+ public final int timeOutSecs; // TODO: Use this or remove it.
+ public final int version;
+ public final int state;
+ public final SparseArray<PageRegion> regions;
+
+ public PageComposition(int timeoutSecs, int version, int state,
+ SparseArray<PageRegion> regions) {
+ this.timeOutSecs = timeoutSecs;
+ this.version = version;
+ this.state = state;
+ this.regions = regions;
+ }
+
+ }
+
+ /**
+ * A region within a {@link PageComposition}.
+ * <p>
+ * See ETSI EN 300 743 7.2.2
+ */
+ private static final class PageRegion {
+
+ public final int horizontalAddress;
+ public final int verticalAddress;
+
+ public PageRegion(int horizontalAddress, int verticalAddress) {
+ this.horizontalAddress = horizontalAddress;
+ this.verticalAddress = verticalAddress;
+ }
+
+ }
+
+ /**
+ * An area of the page composed of a list of objects and a CLUT.
+ * <p>
+ * See ETSI EN 300 743 7.2.3
+ */
+ private static final class RegionComposition {
+
+ public final int id;
+ public final boolean fillFlag;
+ public final int width;
+ public final int height;
+ public final int levelOfCompatibility; // TODO: Use this or remove it.
+ public final int depth;
+ public final int clutId;
+ public final int pixelCode8Bit;
+ public final int pixelCode4Bit;
+ public final int pixelCode2Bit;
+ public final SparseArray<RegionObject> regionObjects;
+
+ public RegionComposition(int id, boolean fillFlag, int width, int height,
+ int levelOfCompatibility, int depth, int clutId, int pixelCode8Bit, int pixelCode4Bit,
+ int pixelCode2Bit, SparseArray<RegionObject> regionObjects) {
+ this.id = id;
+ this.fillFlag = fillFlag;
+ this.width = width;
+ this.height = height;
+ this.levelOfCompatibility = levelOfCompatibility;
+ this.depth = depth;
+ this.clutId = clutId;
+ this.pixelCode8Bit = pixelCode8Bit;
+ this.pixelCode4Bit = pixelCode4Bit;
+ this.pixelCode2Bit = pixelCode2Bit;
+ this.regionObjects = regionObjects;
+ }
+
+ public void mergeFrom(RegionComposition otherRegionComposition) {
+ SparseArray<RegionObject> otherRegionObjects = otherRegionComposition.regionObjects;
+ for (int i = 0; i < otherRegionObjects.size(); i++) {
+ regionObjects.put(otherRegionObjects.keyAt(i), otherRegionObjects.valueAt(i));
+ }
+ }
+
+ }
+
+ /**
+ * An object within a {@link RegionComposition}.
+ * <p>
+ * See ETSI EN 300 743 7.2.3
+ */
+ private static final class RegionObject {
+
+ public final int type; // TODO: Use this or remove it.
+ public final int provider; // TODO: Use this or remove it.
+ public final int horizontalPosition;
+ public final int verticalPosition;
+ public final int foregroundPixelCode; // TODO: Use this or remove it.
+ public final int backgroundPixelCode; // TODO: Use this or remove it.
+
+ public RegionObject(int type, int provider, int horizontalPosition,
+ int verticalPosition, int foregroundPixelCode, int backgroundPixelCode) {
+ this.type = type;
+ this.provider = provider;
+ this.horizontalPosition = horizontalPosition;
+ this.verticalPosition = verticalPosition;
+ this.foregroundPixelCode = foregroundPixelCode;
+ this.backgroundPixelCode = backgroundPixelCode;
+ }
+
+ }
+
+ /**
+ * CLUT family definition containing the color tables for the three bit depths defined
+ * <p>
+ * See ETSI EN 300 743 7.2.4
+ */
+ private static final class ClutDefinition {
+
+ public final int id;
+ public final int[] clutEntries2Bit;
+ public final int[] clutEntries4Bit;
+ public final int[] clutEntries8Bit;
+
+ public ClutDefinition(int id, int[] clutEntries2Bit, int[] clutEntries4Bit,
+ int[] clutEntries8bit) {
+ this.id = id;
+ this.clutEntries2Bit = clutEntries2Bit;
+ this.clutEntries4Bit = clutEntries4Bit;
+ this.clutEntries8Bit = clutEntries8bit;
+ }
+
+ }
+
+ /**
+ * The textual or graphical representation of an object.
+ * <p>
+ * See ETSI EN 300 743 7.2.5
+ */
+ private static final class ObjectData {
+
+ public final int id;
+ public final boolean nonModifyingColorFlag;
+ public final byte[] topFieldData;
+ public final byte[] bottomFieldData;
+
+ public ObjectData(int id, boolean nonModifyingColorFlag, byte[] topFieldData,
+ byte[] bottomFieldData) {
+ this.id = id;
+ this.nonModifyingColorFlag = nonModifyingColorFlag;
+ this.topFieldData = topFieldData;
+ this.bottomFieldData = bottomFieldData;
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java
new file mode 100644
index 0000000000..a624ddaeae
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import java.util.List;
+
+/**
+ * A representation of a DVB subtitle.
+ */
+/* package */ final class DvbSubtitle implements Subtitle {
+
+ private final List<Cue> cues;
+
+ public DvbSubtitle(List<Cue> cues) {
+ this.cues = cues;
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ return 0;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return cues;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java
new file mode 100644
index 0000000000..be6b16c5e6
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java
new file mode 100644
index 0000000000..0b6e0d1f8c
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java
new file mode 100644
index 0000000000..859d240e9b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs;
+
+import android.graphics.Bitmap;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.zip.Inflater;
+
+/** A {@link SimpleSubtitleDecoder} for PGS subtitles. */
+public final class PgsDecoder extends SimpleSubtitleDecoder {
+
+ private static final int SECTION_TYPE_PALETTE = 0x14;
+ private static final int SECTION_TYPE_BITMAP_PICTURE = 0x15;
+ private static final int SECTION_TYPE_IDENTIFIER = 0x16;
+ private static final int SECTION_TYPE_END = 0x80;
+
+ private static final byte INFLATE_HEADER = 0x78;
+
+ private final ParsableByteArray buffer;
+ private final ParsableByteArray inflatedBuffer;
+ private final CueBuilder cueBuilder;
+
+ @Nullable private Inflater inflater;
+
+ public PgsDecoder() {
+ super("PgsDecoder");
+ buffer = new ParsableByteArray();
+ inflatedBuffer = new ParsableByteArray();
+ cueBuilder = new CueBuilder();
+ }
+
+ @Override
+ protected Subtitle decode(byte[] data, int size, boolean reset) throws SubtitleDecoderException {
+ buffer.reset(data, size);
+ maybeInflateData(buffer);
+ cueBuilder.reset();
+ ArrayList<Cue> cues = new ArrayList<>();
+ while (buffer.bytesLeft() >= 3) {
+ Cue cue = readNextSection(buffer, cueBuilder);
+ if (cue != null) {
+ cues.add(cue);
+ }
+ }
+ return new PgsSubtitle(Collections.unmodifiableList(cues));
+ }
+
+ private void maybeInflateData(ParsableByteArray buffer) {
+ if (buffer.bytesLeft() > 0 && buffer.peekUnsignedByte() == INFLATE_HEADER) {
+ if (inflater == null) {
+ inflater = new Inflater();
+ }
+ if (Util.inflate(buffer, inflatedBuffer, inflater)) {
+ buffer.reset(inflatedBuffer.data, inflatedBuffer.limit());
+ } // else assume data is not compressed.
+ }
+ }
+
+ @Nullable
+ private static Cue readNextSection(ParsableByteArray buffer, CueBuilder cueBuilder) {
+ int limit = buffer.limit();
+ int sectionType = buffer.readUnsignedByte();
+ int sectionLength = buffer.readUnsignedShort();
+
+ int nextSectionPosition = buffer.getPosition() + sectionLength;
+ if (nextSectionPosition > limit) {
+ buffer.setPosition(limit);
+ return null;
+ }
+
+ Cue cue = null;
+ switch (sectionType) {
+ case SECTION_TYPE_PALETTE:
+ cueBuilder.parsePaletteSection(buffer, sectionLength);
+ break;
+ case SECTION_TYPE_BITMAP_PICTURE:
+ cueBuilder.parseBitmapSection(buffer, sectionLength);
+ break;
+ case SECTION_TYPE_IDENTIFIER:
+ cueBuilder.parseIdentifierSection(buffer, sectionLength);
+ break;
+ case SECTION_TYPE_END:
+ cue = cueBuilder.build();
+ cueBuilder.reset();
+ break;
+ default:
+ break;
+ }
+
+ buffer.setPosition(nextSectionPosition);
+ return cue;
+ }
+
+ private static final class CueBuilder {
+
+ private final ParsableByteArray bitmapData;
+ private final int[] colors;
+
+ private boolean colorsSet;
+ private int planeWidth;
+ private int planeHeight;
+ private int bitmapX;
+ private int bitmapY;
+ private int bitmapWidth;
+ private int bitmapHeight;
+
+ public CueBuilder() {
+ bitmapData = new ParsableByteArray();
+ colors = new int[256];
+ }
+
+ private void parsePaletteSection(ParsableByteArray buffer, int sectionLength) {
+ if ((sectionLength % 5) != 2) {
+ // Section must be two bytes followed by a whole number of (index, y, cb, cr, a) entries.
+ return;
+ }
+ buffer.skipBytes(2);
+
+ Arrays.fill(colors, 0);
+ int entryCount = sectionLength / 5;
+ for (int i = 0; i < entryCount; i++) {
+ int index = buffer.readUnsignedByte();
+ int y = buffer.readUnsignedByte();
+ int cr = buffer.readUnsignedByte();
+ int cb = buffer.readUnsignedByte();
+ int a = buffer.readUnsignedByte();
+ int r = (int) (y + (1.40200 * (cr - 128)));
+ int g = (int) (y - (0.34414 * (cb - 128)) - (0.71414 * (cr - 128)));
+ int b = (int) (y + (1.77200 * (cb - 128)));
+ colors[index] =
+ (a << 24)
+ | (Util.constrainValue(r, 0, 255) << 16)
+ | (Util.constrainValue(g, 0, 255) << 8)
+ | Util.constrainValue(b, 0, 255);
+ }
+ colorsSet = true;
+ }
+
+ private void parseBitmapSection(ParsableByteArray buffer, int sectionLength) {
+ if (sectionLength < 4) {
+ return;
+ }
+ buffer.skipBytes(3); // Id (2 bytes), version (1 byte).
+ boolean isBaseSection = (0x80 & buffer.readUnsignedByte()) != 0;
+ sectionLength -= 4;
+
+ if (isBaseSection) {
+ if (sectionLength < 7) {
+ return;
+ }
+ int totalLength = buffer.readUnsignedInt24();
+ if (totalLength < 4) {
+ return;
+ }
+ bitmapWidth = buffer.readUnsignedShort();
+ bitmapHeight = buffer.readUnsignedShort();
+ bitmapData.reset(totalLength - 4);
+ sectionLength -= 7;
+ }
+
+ int position = bitmapData.getPosition();
+ int limit = bitmapData.limit();
+ if (position < limit && sectionLength > 0) {
+ int bytesToRead = Math.min(sectionLength, limit - position);
+ buffer.readBytes(bitmapData.data, position, bytesToRead);
+ bitmapData.setPosition(position + bytesToRead);
+ }
+ }
+
+ private void parseIdentifierSection(ParsableByteArray buffer, int sectionLength) {
+ if (sectionLength < 19) {
+ return;
+ }
+ planeWidth = buffer.readUnsignedShort();
+ planeHeight = buffer.readUnsignedShort();
+ buffer.skipBytes(11);
+ bitmapX = buffer.readUnsignedShort();
+ bitmapY = buffer.readUnsignedShort();
+ }
+
+ @Nullable
+ public Cue build() {
+ if (planeWidth == 0
+ || planeHeight == 0
+ || bitmapWidth == 0
+ || bitmapHeight == 0
+ || bitmapData.limit() == 0
+ || bitmapData.getPosition() != bitmapData.limit()
+ || !colorsSet) {
+ return null;
+ }
+ // Build the bitmapData.
+ bitmapData.setPosition(0);
+ int[] argbBitmapData = new int[bitmapWidth * bitmapHeight];
+ int argbBitmapDataIndex = 0;
+ while (argbBitmapDataIndex < argbBitmapData.length) {
+ int colorIndex = bitmapData.readUnsignedByte();
+ if (colorIndex != 0) {
+ argbBitmapData[argbBitmapDataIndex++] = colors[colorIndex];
+ } else {
+ int switchBits = bitmapData.readUnsignedByte();
+ if (switchBits != 0) {
+ int runLength =
+ (switchBits & 0x40) == 0
+ ? (switchBits & 0x3F)
+ : (((switchBits & 0x3F) << 8) | bitmapData.readUnsignedByte());
+ int color = (switchBits & 0x80) == 0 ? 0 : colors[bitmapData.readUnsignedByte()];
+ Arrays.fill(
+ argbBitmapData, argbBitmapDataIndex, argbBitmapDataIndex + runLength, color);
+ argbBitmapDataIndex += runLength;
+ }
+ }
+ }
+ Bitmap bitmap =
+ Bitmap.createBitmap(argbBitmapData, bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888);
+ // Build the cue.
+ return new Cue(
+ bitmap,
+ (float) bitmapX / planeWidth,
+ Cue.ANCHOR_TYPE_START,
+ (float) bitmapY / planeHeight,
+ Cue.ANCHOR_TYPE_START,
+ (float) bitmapWidth / planeWidth,
+ (float) bitmapHeight / planeHeight);
+ }
+
+ public void reset() {
+ planeWidth = 0;
+ planeHeight = 0;
+ bitmapX = 0;
+ bitmapY = 0;
+ bitmapWidth = 0;
+ bitmapHeight = 0;
+ bitmapData.reset(0);
+ colorsSet = false;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java
new file mode 100644
index 0000000000..e875763a45
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2018 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import java.util.List;
+
+/** A representation of a PGS subtitle. */
+/* package */ final class PgsSubtitle implements Subtitle {
+
+ private final List<Cue> cues;
+
+ public PgsSubtitle(List<Cue> cues) {
+ this.cues = cues;
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ return 0;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return cues;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java
new file mode 100644
index 0000000000..ce385ea085
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java
new file mode 100644
index 0000000000..8f878a998e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java
@@ -0,0 +1,446 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa;
+
+import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull;
+
+import android.text.Layout;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
+
+/** A {@link SimpleSubtitleDecoder} for SSA/ASS. */
+public final class SsaDecoder extends SimpleSubtitleDecoder {
+
+ private static final String TAG = "SsaDecoder";
+
+ private static final Pattern SSA_TIMECODE_PATTERN =
+ Pattern.compile("(?:(\\d+):)?(\\d+):(\\d+)[:.](\\d+)");
+
+ /* package */ static final String FORMAT_LINE_PREFIX = "Format:";
+ /* package */ static final String STYLE_LINE_PREFIX = "Style:";
+ private static final String DIALOGUE_LINE_PREFIX = "Dialogue:";
+
+ private static final float DEFAULT_MARGIN = 0.05f;
+
+ private final boolean haveInitializationData;
+ @Nullable private final SsaDialogueFormat dialogueFormatFromInitializationData;
+
+ private @MonotonicNonNull Map<String, SsaStyle> styles;
+
+ /**
+ * The horizontal resolution used by the subtitle author - all cue positions are relative to this.
+ *
+ * <p>Parsed from the {@code PlayResX} value in the {@code [Script Info]} section.
+ */
+ private float screenWidth;
+ /**
+ * The vertical resolution used by the subtitle author - all cue positions are relative to this.
+ *
+ * <p>Parsed from the {@code PlayResY} value in the {@code [Script Info]} section.
+ */
+ private float screenHeight;
+
+ public SsaDecoder() {
+ this(/* initializationData= */ null);
+ }
+
+ /**
+ * Constructs an SsaDecoder with optional format and header info.
+ *
+ * @param initializationData Optional initialization data for the decoder. If not null or empty,
+ * the initialization data must consist of two byte arrays. The first must contain an SSA
+ * format line. The second must contain an SSA header that will be assumed common to all
+ * samples. The header is everything in an SSA file before the {@code [Events]} section (i.e.
+ * {@code [Script Info]} and optional {@code [V4+ Styles]} section.
+ */
+ public SsaDecoder(@Nullable List<byte[]> initializationData) {
+ super("SsaDecoder");
+ screenWidth = Cue.DIMEN_UNSET;
+ screenHeight = Cue.DIMEN_UNSET;
+
+ if (initializationData != null && !initializationData.isEmpty()) {
+ haveInitializationData = true;
+ String formatLine = Util.fromUtf8Bytes(initializationData.get(0));
+ Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX));
+ dialogueFormatFromInitializationData =
+ Assertions.checkNotNull(SsaDialogueFormat.fromFormatLine(formatLine));
+ parseHeader(new ParsableByteArray(initializationData.get(1)));
+ } else {
+ haveInitializationData = false;
+ dialogueFormatFromInitializationData = null;
+ }
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset) {
+ List<List<Cue>> cues = new ArrayList<>();
+ List<Long> cueTimesUs = new ArrayList<>();
+
+ ParsableByteArray data = new ParsableByteArray(bytes, length);
+ if (!haveInitializationData) {
+ parseHeader(data);
+ }
+ parseEventBody(data, cues, cueTimesUs);
+ return new SsaSubtitle(cues, cueTimesUs);
+ }
+
+ /**
+ * Parses the header of the subtitle.
+ *
+ * @param data A {@link ParsableByteArray} from which the header should be read.
+ */
+ private void parseHeader(ParsableByteArray data) {
+ @Nullable String currentLine;
+ while ((currentLine = data.readLine()) != null) {
+ if ("[Script Info]".equalsIgnoreCase(currentLine)) {
+ parseScriptInfo(data);
+ } else if ("[V4+ Styles]".equalsIgnoreCase(currentLine)) {
+ styles = parseStyles(data);
+ } else if ("[V4 Styles]".equalsIgnoreCase(currentLine)) {
+ Log.i(TAG, "[V4 Styles] are not supported");
+ } else if ("[Events]".equalsIgnoreCase(currentLine)) {
+ // We've reached the [Events] section, so the header is over.
+ return;
+ }
+ }
+ }
+
+ /**
+ * Parse the {@code [Script Info]} section.
+ *
+ * <p>When this returns, {@code data.position} will be set to the beginning of the first line that
+ * starts with {@code [} (i.e. the title of the next section).
+ *
+ * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition() position}
+ * set to the beginning of of the first line after {@code [Script Info]}.
+ */
+ private void parseScriptInfo(ParsableByteArray data) {
+ @Nullable String currentLine;
+ while ((currentLine = data.readLine()) != null
+ && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) {
+ String[] infoNameAndValue = currentLine.split(":");
+ if (infoNameAndValue.length != 2) {
+ continue;
+ }
+ switch (Util.toLowerInvariant(infoNameAndValue[0].trim())) {
+ case "playresx":
+ try {
+ screenWidth = Float.parseFloat(infoNameAndValue[1].trim());
+ } catch (NumberFormatException e) {
+ // Ignore invalid PlayResX value.
+ }
+ break;
+ case "playresy":
+ try {
+ screenHeight = Float.parseFloat(infoNameAndValue[1].trim());
+ } catch (NumberFormatException e) {
+ // Ignore invalid PlayResY value.
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Parse the {@code [V4+ Styles]} section.
+ *
+ * <p>When this returns, {@code data.position} will be set to the beginning of the first line that
+ * starts with {@code [} (i.e. the title of the next section).
+ *
+ * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition()} pointing
+ * at the beginning of of the first line after {@code [V4+ Styles]}.
+ */
+ private static Map<String, SsaStyle> parseStyles(ParsableByteArray data) {
+ Map<String, SsaStyle> styles = new LinkedHashMap<>();
+ @Nullable SsaStyle.Format formatInfo = null;
+ @Nullable String currentLine;
+ while ((currentLine = data.readLine()) != null
+ && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) {
+ if (currentLine.startsWith(FORMAT_LINE_PREFIX)) {
+ formatInfo = SsaStyle.Format.fromFormatLine(currentLine);
+ } else if (currentLine.startsWith(STYLE_LINE_PREFIX)) {
+ if (formatInfo == null) {
+ Log.w(TAG, "Skipping 'Style:' line before 'Format:' line: " + currentLine);
+ continue;
+ }
+ @Nullable SsaStyle style = SsaStyle.fromStyleLine(currentLine, formatInfo);
+ if (style != null) {
+ styles.put(style.name, style);
+ }
+ }
+ }
+ return styles;
+ }
+
+ /**
+ * Parses the event body of the subtitle.
+ *
+ * @param data A {@link ParsableByteArray} from which the body should be read.
+ * @param cues A list to which parsed cues will be added.
+ * @param cueTimesUs A sorted list to which parsed cue timestamps will be added.
+ */
+ private void parseEventBody(ParsableByteArray data, List<List<Cue>> cues, List<Long> cueTimesUs) {
+ @Nullable
+ SsaDialogueFormat format = haveInitializationData ? dialogueFormatFromInitializationData : null;
+ @Nullable String currentLine;
+ while ((currentLine = data.readLine()) != null) {
+ if (currentLine.startsWith(FORMAT_LINE_PREFIX)) {
+ format = SsaDialogueFormat.fromFormatLine(currentLine);
+ } else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) {
+ if (format == null) {
+ Log.w(TAG, "Skipping dialogue line before complete format: " + currentLine);
+ continue;
+ }
+ parseDialogueLine(currentLine, format, cues, cueTimesUs);
+ }
+ }
+ }
+
+ /**
+ * Parses a dialogue line.
+ *
+ * @param dialogueLine The dialogue values (i.e. everything after {@code Dialogue:}).
+ * @param format The dialogue format to use when parsing {@code dialogueLine}.
+ * @param cues A list to which parsed cues will be added.
+ * @param cueTimesUs A sorted list to which parsed cue timestamps will be added.
+ */
+ private void parseDialogueLine(
+ String dialogueLine, SsaDialogueFormat format, List<List<Cue>> cues, List<Long> cueTimesUs) {
+ Assertions.checkArgument(dialogueLine.startsWith(DIALOGUE_LINE_PREFIX));
+ String[] lineValues =
+ dialogueLine.substring(DIALOGUE_LINE_PREFIX.length()).split(",", format.length);
+ if (lineValues.length != format.length) {
+ Log.w(TAG, "Skipping dialogue line with fewer columns than format: " + dialogueLine);
+ return;
+ }
+
+ long startTimeUs = parseTimecodeUs(lineValues[format.startTimeIndex]);
+ if (startTimeUs == C.TIME_UNSET) {
+ Log.w(TAG, "Skipping invalid timing: " + dialogueLine);
+ return;
+ }
+
+ long endTimeUs = parseTimecodeUs(lineValues[format.endTimeIndex]);
+ if (endTimeUs == C.TIME_UNSET) {
+ Log.w(TAG, "Skipping invalid timing: " + dialogueLine);
+ return;
+ }
+
+ @Nullable
+ SsaStyle style =
+ styles != null && format.styleIndex != C.INDEX_UNSET
+ ? styles.get(lineValues[format.styleIndex].trim())
+ : null;
+ String rawText = lineValues[format.textIndex];
+ SsaStyle.Overrides styleOverrides = SsaStyle.Overrides.parseFromDialogue(rawText);
+ String text =
+ SsaStyle.Overrides.stripStyleOverrides(rawText)
+ .replaceAll("\\\\N", "\n")
+ .replaceAll("\\\\n", "\n");
+ Cue cue = createCue(text, style, styleOverrides, screenWidth, screenHeight);
+
+ int startTimeIndex = addCuePlacerholderByTime(startTimeUs, cueTimesUs, cues);
+ int endTimeIndex = addCuePlacerholderByTime(endTimeUs, cueTimesUs, cues);
+ // Iterate on cues from startTimeIndex until endTimeIndex, adding the current cue.
+ for (int i = startTimeIndex; i < endTimeIndex; i++) {
+ cues.get(i).add(cue);
+ }
+ }
+
+ /**
+ * Parses an SSA timecode string.
+ *
+ * @param timeString The string to parse.
+ * @return The parsed timestamp in microseconds.
+ */
+ private static long parseTimecodeUs(String timeString) {
+ Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim());
+ if (!matcher.matches()) {
+ return C.TIME_UNSET;
+ }
+ long timestampUs =
+ Long.parseLong(castNonNull(matcher.group(1))) * 60 * 60 * C.MICROS_PER_SECOND;
+ timestampUs += Long.parseLong(castNonNull(matcher.group(2))) * 60 * C.MICROS_PER_SECOND;
+ timestampUs += Long.parseLong(castNonNull(matcher.group(3))) * C.MICROS_PER_SECOND;
+ timestampUs += Long.parseLong(castNonNull(matcher.group(4))) * 10000; // 100ths of a second.
+ return timestampUs;
+ }
+
+ private static Cue createCue(
+ String text,
+ @Nullable SsaStyle style,
+ SsaStyle.Overrides styleOverrides,
+ float screenWidth,
+ float screenHeight) {
+ @SsaStyle.SsaAlignment int alignment;
+ if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) {
+ alignment = styleOverrides.alignment;
+ } else if (style != null) {
+ alignment = style.alignment;
+ } else {
+ alignment = SsaStyle.SSA_ALIGNMENT_UNKNOWN;
+ }
+ @Cue.AnchorType int positionAnchor = toPositionAnchor(alignment);
+ @Cue.AnchorType int lineAnchor = toLineAnchor(alignment);
+
+ float position;
+ float line;
+ if (styleOverrides.position != null
+ && screenHeight != Cue.DIMEN_UNSET
+ && screenWidth != Cue.DIMEN_UNSET) {
+ position = styleOverrides.position.x / screenWidth;
+ line = styleOverrides.position.y / screenHeight;
+ } else {
+ // TODO: Read the MarginL, MarginR and MarginV values from the Style & Dialogue lines.
+ position = computeDefaultLineOrPosition(positionAnchor);
+ line = computeDefaultLineOrPosition(lineAnchor);
+ }
+
+ return new Cue(
+ text,
+ toTextAlignment(alignment),
+ line,
+ Cue.LINE_TYPE_FRACTION,
+ lineAnchor,
+ position,
+ positionAnchor,
+ /* size= */ Cue.DIMEN_UNSET);
+ }
+
+ @Nullable
+ private static Layout.Alignment toTextAlignment(@SsaStyle.SsaAlignment int alignment) {
+ switch (alignment) {
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_TOP_LEFT:
+ return Layout.Alignment.ALIGN_NORMAL;
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_TOP_CENTER:
+ return Layout.Alignment.ALIGN_CENTER;
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT:
+ case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT:
+ return Layout.Alignment.ALIGN_OPPOSITE;
+ case SsaStyle.SSA_ALIGNMENT_UNKNOWN:
+ return null;
+ default:
+ Log.w(TAG, "Unknown alignment: " + alignment);
+ return null;
+ }
+ }
+
+ @Cue.AnchorType
+ private static int toLineAnchor(@SsaStyle.SsaAlignment int alignment) {
+ switch (alignment) {
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT:
+ return Cue.ANCHOR_TYPE_END;
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT:
+ return Cue.ANCHOR_TYPE_MIDDLE;
+ case SsaStyle.SSA_ALIGNMENT_TOP_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_TOP_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT:
+ return Cue.ANCHOR_TYPE_START;
+ case SsaStyle.SSA_ALIGNMENT_UNKNOWN:
+ return Cue.TYPE_UNSET;
+ default:
+ Log.w(TAG, "Unknown alignment: " + alignment);
+ return Cue.TYPE_UNSET;
+ }
+ }
+
+ @Cue.AnchorType
+ private static int toPositionAnchor(@SsaStyle.SsaAlignment int alignment) {
+ switch (alignment) {
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT:
+ case SsaStyle.SSA_ALIGNMENT_TOP_LEFT:
+ return Cue.ANCHOR_TYPE_START;
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER:
+ case SsaStyle.SSA_ALIGNMENT_TOP_CENTER:
+ return Cue.ANCHOR_TYPE_MIDDLE;
+ case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT:
+ case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT:
+ case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT:
+ return Cue.ANCHOR_TYPE_END;
+ case SsaStyle.SSA_ALIGNMENT_UNKNOWN:
+ return Cue.TYPE_UNSET;
+ default:
+ Log.w(TAG, "Unknown alignment: " + alignment);
+ return Cue.TYPE_UNSET;
+ }
+ }
+
+ private static float computeDefaultLineOrPosition(@Cue.AnchorType int anchor) {
+ switch (anchor) {
+ case Cue.ANCHOR_TYPE_START:
+ return DEFAULT_MARGIN;
+ case Cue.ANCHOR_TYPE_MIDDLE:
+ return 0.5f;
+ case Cue.ANCHOR_TYPE_END:
+ return 1.0f - DEFAULT_MARGIN;
+ case Cue.TYPE_UNSET:
+ default:
+ return Cue.DIMEN_UNSET;
+ }
+ }
+
+ /**
+ * Searches for {@code timeUs} in {@code sortedCueTimesUs}, inserting it if it's not found, and
+ * returns the index.
+ *
+ * <p>If it's inserted, we also insert a matching entry to {@code cues}.
+ */
+ private static int addCuePlacerholderByTime(
+ long timeUs, List<Long> sortedCueTimesUs, List<List<Cue>> cues) {
+ int insertionIndex = 0;
+ for (int i = sortedCueTimesUs.size() - 1; i >= 0; i--) {
+ if (sortedCueTimesUs.get(i) == timeUs) {
+ return i;
+ }
+
+ if (sortedCueTimesUs.get(i) < timeUs) {
+ insertionIndex = i + 1;
+ break;
+ }
+ }
+ sortedCueTimesUs.add(insertionIndex, timeUs);
+ // Copy over cues from left, or use an empty list if we're inserting at the beginning.
+ cues.add(
+ insertionIndex,
+ insertionIndex == 0 ? new ArrayList<>() : new ArrayList<>(cues.get(insertionIndex - 1)));
+ return insertionIndex;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java
new file mode 100644
index 0000000000..312c779e23
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (C) 2019 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa;
+
+import static org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder.FORMAT_LINE_PREFIX;
+
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+
+/**
+ * Represents a {@code Format:} line from the {@code [Events]} section
+ *
+ * <p>The indices are used to determine the location of particular properties in each {@code
+ * Dialogue:} line.
+ */
+/* package */ final class SsaDialogueFormat {
+
+ public final int startTimeIndex;
+ public final int endTimeIndex;
+ public final int styleIndex;
+ public final int textIndex;
+ public final int length;
+
+ private SsaDialogueFormat(
+ int startTimeIndex, int endTimeIndex, int styleIndex, int textIndex, int length) {
+ this.startTimeIndex = startTimeIndex;
+ this.endTimeIndex = endTimeIndex;
+ this.styleIndex = styleIndex;
+ this.textIndex = textIndex;
+ this.length = length;
+ }
+
+ /**
+ * Parses the format info from a 'Format:' line in the [Events] section.
+ *
+ * @return the parsed info, or null if {@code formatLine} doesn't contain both 'start' and 'end'.
+ */
+ @Nullable
+ public static SsaDialogueFormat fromFormatLine(String formatLine) {
+ int startTimeIndex = C.INDEX_UNSET;
+ int endTimeIndex = C.INDEX_UNSET;
+ int styleIndex = C.INDEX_UNSET;
+ int textIndex = C.INDEX_UNSET;
+ Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX));
+ String[] keys = TextUtils.split(formatLine.substring(FORMAT_LINE_PREFIX.length()), ",");
+ for (int i = 0; i < keys.length; i++) {
+ switch (Util.toLowerInvariant(keys[i].trim())) {
+ case "start":
+ startTimeIndex = i;
+ break;
+ case "end":
+ endTimeIndex = i;
+ break;
+ case "style":
+ styleIndex = i;
+ break;
+ case "text":
+ textIndex = i;
+ break;
+ }
+ }
+ return (startTimeIndex != C.INDEX_UNSET && endTimeIndex != C.INDEX_UNSET)
+ ? new SsaDialogueFormat(startTimeIndex, endTimeIndex, styleIndex, textIndex, keys.length)
+ : null;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java
new file mode 100644
index 0000000000..3c3639a3fb
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java
@@ -0,0 +1,301 @@
+/*
+ * Copyright (C) 2019 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa;
+
+import static org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder.STYLE_LINE_PREFIX;
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.graphics.PointF;
+import android.text.TextUtils;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Represents a line from an SSA/ASS {@code [V4+ Styles]} section. */
+/* package */ final class SsaStyle {
+
+ private static final String TAG = "SsaStyle";
+
+ /**
+ * The SSA/ASS alignments.
+ *
+ * <p>Allowed values:
+ *
+ * <ul>
+ * <li>{@link #SSA_ALIGNMENT_UNKNOWN}
+ * <li>{@link #SSA_ALIGNMENT_BOTTOM_LEFT}
+ * <li>{@link #SSA_ALIGNMENT_BOTTOM_CENTER}
+ * <li>{@link #SSA_ALIGNMENT_BOTTOM_RIGHT}
+ * <li>{@link #SSA_ALIGNMENT_MIDDLE_LEFT}
+ * <li>{@link #SSA_ALIGNMENT_MIDDLE_CENTER}
+ * <li>{@link #SSA_ALIGNMENT_MIDDLE_RIGHT}
+ * <li>{@link #SSA_ALIGNMENT_TOP_LEFT}
+ * <li>{@link #SSA_ALIGNMENT_TOP_CENTER}
+ * <li>{@link #SSA_ALIGNMENT_TOP_RIGHT}
+ * </ul>
+ */
+ @IntDef({
+ SSA_ALIGNMENT_UNKNOWN,
+ SSA_ALIGNMENT_BOTTOM_LEFT,
+ SSA_ALIGNMENT_BOTTOM_CENTER,
+ SSA_ALIGNMENT_BOTTOM_RIGHT,
+ SSA_ALIGNMENT_MIDDLE_LEFT,
+ SSA_ALIGNMENT_MIDDLE_CENTER,
+ SSA_ALIGNMENT_MIDDLE_RIGHT,
+ SSA_ALIGNMENT_TOP_LEFT,
+ SSA_ALIGNMENT_TOP_CENTER,
+ SSA_ALIGNMENT_TOP_RIGHT,
+ })
+ @Documented
+ @Retention(SOURCE)
+ public @interface SsaAlignment {}
+
+ // The numbering follows the ASS (v4+) spec (i.e. the points on the number pad).
+ public static final int SSA_ALIGNMENT_UNKNOWN = -1;
+ public static final int SSA_ALIGNMENT_BOTTOM_LEFT = 1;
+ public static final int SSA_ALIGNMENT_BOTTOM_CENTER = 2;
+ public static final int SSA_ALIGNMENT_BOTTOM_RIGHT = 3;
+ public static final int SSA_ALIGNMENT_MIDDLE_LEFT = 4;
+ public static final int SSA_ALIGNMENT_MIDDLE_CENTER = 5;
+ public static final int SSA_ALIGNMENT_MIDDLE_RIGHT = 6;
+ public static final int SSA_ALIGNMENT_TOP_LEFT = 7;
+ public static final int SSA_ALIGNMENT_TOP_CENTER = 8;
+ public static final int SSA_ALIGNMENT_TOP_RIGHT = 9;
+
+ public final String name;
+ @SsaAlignment public final int alignment;
+
+ private SsaStyle(String name, @SsaAlignment int alignment) {
+ this.name = name;
+ this.alignment = alignment;
+ }
+
+ @Nullable
+ public static SsaStyle fromStyleLine(String styleLine, Format format) {
+ Assertions.checkArgument(styleLine.startsWith(STYLE_LINE_PREFIX));
+ String[] styleValues = TextUtils.split(styleLine.substring(STYLE_LINE_PREFIX.length()), ",");
+ if (styleValues.length != format.length) {
+ Log.w(
+ TAG,
+ Util.formatInvariant(
+ "Skipping malformed 'Style:' line (expected %s values, found %s): '%s'",
+ format.length, styleValues.length, styleLine));
+ return null;
+ }
+ try {
+ return new SsaStyle(
+ styleValues[format.nameIndex].trim(), parseAlignment(styleValues[format.alignmentIndex]));
+ } catch (RuntimeException e) {
+ Log.w(TAG, "Skipping malformed 'Style:' line: '" + styleLine + "'", e);
+ return null;
+ }
+ }
+
+ @SsaAlignment
+ private static int parseAlignment(String alignmentStr) {
+ try {
+ @SsaAlignment int alignment = Integer.parseInt(alignmentStr.trim());
+ if (isValidAlignment(alignment)) {
+ return alignment;
+ }
+ } catch (NumberFormatException e) {
+ // Swallow the exception and return UNKNOWN below.
+ }
+ Log.w(TAG, "Ignoring unknown alignment: " + alignmentStr);
+ return SSA_ALIGNMENT_UNKNOWN;
+ }
+
+ private static boolean isValidAlignment(@SsaAlignment int alignment) {
+ switch (alignment) {
+ case SSA_ALIGNMENT_BOTTOM_CENTER:
+ case SSA_ALIGNMENT_BOTTOM_LEFT:
+ case SSA_ALIGNMENT_BOTTOM_RIGHT:
+ case SSA_ALIGNMENT_MIDDLE_CENTER:
+ case SSA_ALIGNMENT_MIDDLE_LEFT:
+ case SSA_ALIGNMENT_MIDDLE_RIGHT:
+ case SSA_ALIGNMENT_TOP_CENTER:
+ case SSA_ALIGNMENT_TOP_LEFT:
+ case SSA_ALIGNMENT_TOP_RIGHT:
+ return true;
+ case SSA_ALIGNMENT_UNKNOWN:
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Represents a {@code Format:} line from the {@code [V4+ Styles]} section
+ *
+ * <p>The indices are used to determine the location of particular properties in each {@code
+ * Style:} line.
+ */
+ /* package */ static final class Format {
+
+ public final int nameIndex;
+ public final int alignmentIndex;
+ public final int length;
+
+ private Format(int nameIndex, int alignmentIndex, int length) {
+ this.nameIndex = nameIndex;
+ this.alignmentIndex = alignmentIndex;
+ this.length = length;
+ }
+
+ /**
+ * Parses the format info from a 'Format:' line in the [V4+ Styles] section.
+ *
+ * @return the parsed info, or null if {@code styleFormatLine} doesn't contain 'name'.
+ */
+ @Nullable
+ public static Format fromFormatLine(String styleFormatLine) {
+ int nameIndex = C.INDEX_UNSET;
+ int alignmentIndex = C.INDEX_UNSET;
+ String[] keys =
+ TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ",");
+ for (int i = 0; i < keys.length; i++) {
+ switch (Util.toLowerInvariant(keys[i].trim())) {
+ case "name":
+ nameIndex = i;
+ break;
+ case "alignment":
+ alignmentIndex = i;
+ break;
+ }
+ }
+ return nameIndex != C.INDEX_UNSET ? new Format(nameIndex, alignmentIndex, keys.length) : null;
+ }
+ }
+
+ /**
+ * Represents the style override information parsed from an SSA/ASS dialogue line.
+ *
+ * <p>Overrides are contained in braces embedded in the dialogue text of the cue.
+ */
+ /* package */ static final class Overrides {
+
+ private static final String TAG = "SsaStyle.Overrides";
+
+ /** Matches "{foo}" and returns "foo" in group 1 */
+ // Warning that \\} can be replaced with } is bogus [internal: b/144480183].
+ private static final Pattern BRACES_PATTERN = Pattern.compile("\\{([^}]*)\\}");
+
+ private static final String PADDED_DECIMAL_PATTERN = "\\s*\\d+(?:\\.\\d+)?\\s*";
+
+ /** Matches "\pos(x,y)" and returns "x" in group 1 and "y" in group 2 */
+ private static final Pattern POSITION_PATTERN =
+ Pattern.compile(Util.formatInvariant("\\\\pos\\((%1$s),(%1$s)\\)", PADDED_DECIMAL_PATTERN));
+ /** Matches "\move(x1,y1,x2,y2[,t1,t2])" and returns "x2" in group 1 and "y2" in group 2 */
+ private static final Pattern MOVE_PATTERN =
+ Pattern.compile(
+ Util.formatInvariant(
+ "\\\\move\\(%1$s,%1$s,(%1$s),(%1$s)(?:,%1$s,%1$s)?\\)", PADDED_DECIMAL_PATTERN));
+
+ /** Matches "\anx" and returns x in group 1 */
+ private static final Pattern ALIGNMENT_OVERRIDE_PATTERN = Pattern.compile("\\\\an(\\d+)");
+
+ @SsaAlignment public final int alignment;
+ @Nullable public final PointF position;
+
+ private Overrides(@SsaAlignment int alignment, @Nullable PointF position) {
+ this.alignment = alignment;
+ this.position = position;
+ }
+
+ public static Overrides parseFromDialogue(String text) {
+ @SsaAlignment int alignment = SSA_ALIGNMENT_UNKNOWN;
+ PointF position = null;
+ Matcher matcher = BRACES_PATTERN.matcher(text);
+ while (matcher.find()) {
+ String braceContents = matcher.group(1);
+ try {
+ PointF parsedPosition = parsePosition(braceContents);
+ if (parsedPosition != null) {
+ position = parsedPosition;
+ }
+ } catch (RuntimeException e) {
+ // Ignore invalid \pos() or \move() function.
+ }
+ try {
+ @SsaAlignment int parsedAlignment = parseAlignmentOverride(braceContents);
+ if (parsedAlignment != SSA_ALIGNMENT_UNKNOWN) {
+ alignment = parsedAlignment;
+ }
+ } catch (RuntimeException e) {
+ // Ignore invalid \an alignment override.
+ }
+ }
+ return new Overrides(alignment, position);
+ }
+
+ public static String stripStyleOverrides(String dialogueLine) {
+ return BRACES_PATTERN.matcher(dialogueLine).replaceAll("");
+ }
+
+ /**
+ * Parses the position from a style override, returns null if no position is found.
+ *
+ * <p>The attribute is expected to be in the form {@code \pos(x,y)} or {@code
+ * \move(x1,y1,x2,y2,startTime,endTime)} (startTime and endTime are optional). In the case of
+ * {@code \move()}, this returns {@code (x2, y2)} (i.e. the end position of the move).
+ *
+ * @param styleOverride The string to parse.
+ * @return The parsed position, or null if no position is found.
+ */
+ @Nullable
+ private static PointF parsePosition(String styleOverride) {
+ Matcher positionMatcher = POSITION_PATTERN.matcher(styleOverride);
+ Matcher moveMatcher = MOVE_PATTERN.matcher(styleOverride);
+ boolean hasPosition = positionMatcher.find();
+ boolean hasMove = moveMatcher.find();
+
+ String x;
+ String y;
+ if (hasPosition) {
+ if (hasMove) {
+ Log.i(
+ TAG,
+ "Override has both \\pos(x,y) and \\move(x1,y1,x2,y2); using \\pos values. override='"
+ + styleOverride
+ + "'");
+ }
+ x = positionMatcher.group(1);
+ y = positionMatcher.group(2);
+ } else if (hasMove) {
+ x = moveMatcher.group(1);
+ y = moveMatcher.group(2);
+ } else {
+ return null;
+ }
+ return new PointF(
+ Float.parseFloat(Assertions.checkNotNull(x).trim()),
+ Float.parseFloat(Assertions.checkNotNull(y).trim()));
+ }
+
+ @SsaAlignment
+ private static int parseAlignmentOverride(String braceContents) {
+ Matcher matcher = ALIGNMENT_OVERRIDE_PATTERN.matcher(braceContents);
+ return matcher.find() ? parseAlignment(matcher.group(1)) : SSA_ALIGNMENT_UNKNOWN;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java
new file mode 100644
index 0000000000..fb0544156d
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2017 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A representation of an SSA/ASS subtitle.
+ */
+/* package */ final class SsaSubtitle implements Subtitle {
+
+ private final List<List<Cue>> cues;
+ private final List<Long> cueTimesUs;
+
+ /**
+ * @param cues The cues in the subtitle.
+ * @param cueTimesUs The cue times, in microseconds.
+ */
+ public SsaSubtitle(List<List<Cue>> cues, List<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.size() ? index : C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return cueTimesUs.size();
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ Assertions.checkArgument(index >= 0);
+ Assertions.checkArgument(index < cueTimesUs.size());
+ return cueTimesUs.get(index);
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false);
+ if (index == -1) {
+ // timeUs is earlier than the start of the first cue.
+ return Collections.emptyList();
+ } else {
+ return cues.get(index);
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java
new file mode 100644
index 0000000000..bc4b625d77
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java
new file mode 100644
index 0000000000..36ebf6ead0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java
@@ -0,0 +1,259 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip;
+
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.LongArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import java.util.ArrayList;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A {@link SimpleSubtitleDecoder} for SubRip.
+ */
+public final class SubripDecoder extends SimpleSubtitleDecoder {
+
+ // 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";
+
+ // 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+))?";
+ private static final Pattern SUBRIP_TIMING_LINE =
+ Pattern.compile("\\s*(" + SUBRIP_TIMECODE + ")\\s*-->\\s*(" + SUBRIP_TIMECODE + ")\\s*");
+
+ // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183].
+ private static final Pattern SUBRIP_TAG_PATTERN = Pattern.compile("\\{\\\\.*?\\}");
+ private static final String SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}";
+
+ // Alignment tags for SSA V4+.
+ private static final String ALIGN_BOTTOM_LEFT = "{\\an1}";
+ private static final String ALIGN_BOTTOM_MID = "{\\an2}";
+ private static final String ALIGN_BOTTOM_RIGHT = "{\\an3}";
+ private static final String ALIGN_MID_LEFT = "{\\an4}";
+ private static final String ALIGN_MID_MID = "{\\an5}";
+ private static final String ALIGN_MID_RIGHT = "{\\an6}";
+ private static final String ALIGN_TOP_LEFT = "{\\an7}";
+ private static final String ALIGN_TOP_MID = "{\\an8}";
+ private static final String ALIGN_TOP_RIGHT = "{\\an9}";
+
+ private final StringBuilder textBuilder;
+ private final ArrayList<String> tags;
+
+ public SubripDecoder() {
+ super("SubripDecoder");
+ textBuilder = new StringBuilder();
+ tags = new ArrayList<>();
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset) {
+ ArrayList<Cue> cues = new ArrayList<>();
+ LongArray cueTimesUs = new LongArray();
+ ParsableByteArray subripData = new ParsableByteArray(bytes, length);
+
+ @Nullable String currentLine;
+ while ((currentLine = subripData.readLine()) != null) {
+ if (currentLine.length() == 0) {
+ // Skip blank lines.
+ continue;
+ }
+
+ // Parse the index line as a sanity check.
+ try {
+ Integer.parseInt(currentLine);
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Skipping invalid index: " + currentLine);
+ continue;
+ }
+
+ // Read and parse the timing line.
+ currentLine = subripData.readLine();
+ if (currentLine == null) {
+ Log.w(TAG, "Unexpected end");
+ break;
+ }
+
+ Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine);
+ if (matcher.matches()) {
+ cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1));
+ cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6));
+ } else {
+ Log.w(TAG, "Skipping invalid timing: " + currentLine);
+ continue;
+ }
+
+ // Read and parse the text and tags.
+ textBuilder.setLength(0);
+ tags.clear();
+ currentLine = subripData.readLine();
+ while (!TextUtils.isEmpty(currentLine)) {
+ if (textBuilder.length() > 0) {
+ textBuilder.append("<br>");
+ }
+ textBuilder.append(processLine(currentLine, tags));
+ currentLine = subripData.readLine();
+ }
+
+ Spanned text = Html.fromHtml(textBuilder.toString());
+
+ @Nullable String alignmentTag = null;
+ for (int i = 0; i < tags.size(); i++) {
+ String tag = tags.get(i);
+ if (tag.matches(SUBRIP_ALIGNMENT_TAG)) {
+ alignmentTag = tag;
+ // Subsequent alignment tags should be ignored.
+ break;
+ }
+ }
+ cues.add(buildCue(text, alignmentTag));
+ cues.add(Cue.EMPTY);
+ }
+
+ Cue[] cuesArray = new Cue[cues.size()];
+ cues.toArray(cuesArray);
+ long[] cueTimesUsArray = cueTimesUs.toArray();
+ return new SubripSubtitle(cuesArray, cueTimesUsArray);
+ }
+
+ /**
+ * Trims and removes tags from the given line. The removed tags are added to {@code tags}.
+ *
+ * @param line The line to process.
+ * @param tags A list to which removed tags will be added.
+ * @return The processed line.
+ */
+ private String processLine(String line, ArrayList<String> tags) {
+ line = line.trim();
+
+ int removedCharacterCount = 0;
+ StringBuilder processedLine = new StringBuilder(line);
+ Matcher matcher = SUBRIP_TAG_PATTERN.matcher(line);
+ while (matcher.find()) {
+ String tag = matcher.group();
+ tags.add(tag);
+ int start = matcher.start() - removedCharacterCount;
+ int tagLength = tag.length();
+ processedLine.replace(start, /* end= */ start + tagLength, /* str= */ "");
+ removedCharacterCount += tagLength;
+ }
+
+ return processedLine.toString();
+ }
+
+ /**
+ * Build a {@link Cue} based on the given text and alignment tag.
+ *
+ * @param text The text.
+ * @param alignmentTag The alignment tag, or {@code null} if no alignment tag is available.
+ * @return Built cue
+ */
+ private Cue buildCue(Spanned text, @Nullable String alignmentTag) {
+ if (alignmentTag == null) {
+ return new Cue(text);
+ }
+
+ // Horizontal alignment.
+ @Cue.AnchorType int positionAnchor;
+ switch (alignmentTag) {
+ case ALIGN_BOTTOM_LEFT:
+ case ALIGN_MID_LEFT:
+ case ALIGN_TOP_LEFT:
+ positionAnchor = Cue.ANCHOR_TYPE_START;
+ break;
+ case ALIGN_BOTTOM_RIGHT:
+ case ALIGN_MID_RIGHT:
+ case ALIGN_TOP_RIGHT:
+ positionAnchor = Cue.ANCHOR_TYPE_END;
+ break;
+ case ALIGN_BOTTOM_MID:
+ case ALIGN_MID_MID:
+ case ALIGN_TOP_MID:
+ default:
+ positionAnchor = Cue.ANCHOR_TYPE_MIDDLE;
+ break;
+ }
+
+ // Vertical alignment.
+ @Cue.AnchorType int lineAnchor;
+ switch (alignmentTag) {
+ case ALIGN_BOTTOM_LEFT:
+ case ALIGN_BOTTOM_MID:
+ case ALIGN_BOTTOM_RIGHT:
+ lineAnchor = Cue.ANCHOR_TYPE_END;
+ break;
+ case ALIGN_TOP_LEFT:
+ case ALIGN_TOP_MID:
+ case ALIGN_TOP_RIGHT:
+ lineAnchor = Cue.ANCHOR_TYPE_START;
+ break;
+ case ALIGN_MID_LEFT:
+ case ALIGN_MID_MID:
+ case ALIGN_MID_RIGHT:
+ default:
+ lineAnchor = Cue.ANCHOR_TYPE_MIDDLE;
+ break;
+ }
+
+ return new Cue(
+ text,
+ /* textAlignment= */ null,
+ getFractionalPositionForAnchorType(lineAnchor),
+ Cue.LINE_TYPE_FRACTION,
+ lineAnchor,
+ getFractionalPositionForAnchorType(positionAnchor),
+ positionAnchor,
+ Cue.DIMEN_UNSET);
+ }
+
+ private static long parseTimecode(Matcher matcher, int groupOffset) {
+ @Nullable String hours = matcher.group(groupOffset + 1);
+ long timestampMs = hours != null ? Long.parseLong(hours) * 60 * 60 * 1000 : 0;
+ timestampMs += Long.parseLong(matcher.group(groupOffset + 2)) * 60 * 1000;
+ timestampMs += Long.parseLong(matcher.group(groupOffset + 3)) * 1000;
+ @Nullable String millis = matcher.group(groupOffset + 4);
+ if (millis != null) {
+ timestampMs += Long.parseLong(millis);
+ }
+ return timestampMs * 1000;
+ }
+
+ /* package */ static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) {
+ switch (anchorType) {
+ case Cue.ANCHOR_TYPE_START:
+ return SubripDecoder.START_FRACTION;
+ case Cue.ANCHOR_TYPE_MIDDLE:
+ return SubripDecoder.MID_FRACTION;
+ case Cue.ANCHOR_TYPE_END:
+ return SubripDecoder.END_FRACTION;
+ case Cue.TYPE_UNSET:
+ default:
+ // Should never happen.
+ throw new IllegalArgumentException();
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java
new file mode 100644
index 0000000000..d011f5d7c5
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java
@@ -0,0 +1,72 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+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<Cue> 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/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java
new file mode 100644
index 0000000000..fb990cb748
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java
new file mode 100644
index 0000000000..502281c2de
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java
@@ -0,0 +1,756 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import android.text.Layout;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ColorParser;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.XmlPullParserUtil;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.ArrayDeque;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.xmlpull.v1.XmlPullParser;
+import org.xmlpull.v1.XmlPullParserException;
+import org.xmlpull.v1.XmlPullParserFactory;
+
+/**
+ * A {@link SimpleSubtitleDecoder} for TTML supporting the DFXP presentation profile. Features
+ * supported by this decoder are:
+ *
+ * <ul>
+ * <li>content
+ * <li>core
+ * <li>presentation
+ * <li>profile
+ * <li>structure
+ * <li>time-offset
+ * <li>timing
+ * <li>tickRate
+ * <li>time-clock-with-frames
+ * <li>time-clock
+ * <li>time-offset-with-frames
+ * <li>time-offset-with-ticks
+ * <li>cell-resolution
+ * </ul>
+ *
+ * @see <a href="http://www.w3.org/TR/ttaf1-dfxp/">TTML specification</a>
+ */
+public final class TtmlDecoder extends SimpleSubtitleDecoder {
+
+ private static final String TAG = "TtmlDecoder";
+
+ private static final String TTP = "http://www.w3.org/ns/ttml#parameter";
+
+ private static final String ATTR_BEGIN = "begin";
+ private static final String ATTR_DURATION = "dur";
+ private static final String ATTR_END = "end";
+ private static final String ATTR_STYLE = "style";
+ private static final String ATTR_REGION = "region";
+ private static final String ATTR_IMAGE = "backgroundImage";
+
+ private static final Pattern CLOCK_TIME =
+ Pattern.compile("^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])"
+ + "(?:(\\.[0-9]+)|:([0-9][0-9])(?:\\.([0-9]+))?)?$");
+ private static final Pattern OFFSET_TIME =
+ Pattern.compile("^([0-9]+(?:\\.[0-9]+)?)(h|m|s|ms|f|t)$");
+ private static final Pattern FONT_SIZE = Pattern.compile("^(([0-9]*.)?[0-9]+)(px|em|%)$");
+ private static final Pattern PERCENTAGE_COORDINATES =
+ Pattern.compile("^(\\d+\\.?\\d*?)% (\\d+\\.?\\d*?)%$");
+ private static final Pattern PIXEL_COORDINATES =
+ Pattern.compile("^(\\d+\\.?\\d*?)px (\\d+\\.?\\d*?)px$");
+ private static final Pattern CELL_RESOLUTION = Pattern.compile("^(\\d+) (\\d+)$");
+
+ private static final int DEFAULT_FRAME_RATE = 30;
+
+ private static final FrameAndTickRate DEFAULT_FRAME_AND_TICK_RATE =
+ new FrameAndTickRate(DEFAULT_FRAME_RATE, 1, 1);
+ private static final CellResolution DEFAULT_CELL_RESOLUTION =
+ new CellResolution(/* columns= */ 32, /* rows= */ 15);
+
+ private final XmlPullParserFactory xmlParserFactory;
+
+ public TtmlDecoder() {
+ super("TtmlDecoder");
+ try {
+ xmlParserFactory = XmlPullParserFactory.newInstance();
+ xmlParserFactory.setNamespaceAware(true);
+ } catch (XmlPullParserException e) {
+ throw new RuntimeException("Couldn't create XmlPullParserFactory instance", e);
+ }
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset)
+ throws SubtitleDecoderException {
+ try {
+ XmlPullParser xmlParser = xmlParserFactory.newPullParser();
+ Map<String, TtmlStyle> globalStyles = new HashMap<>();
+ Map<String, TtmlRegion> regionMap = new HashMap<>();
+ Map<String, String> imageMap = new HashMap<>();
+ regionMap.put(TtmlNode.ANONYMOUS_REGION_ID, new TtmlRegion(null));
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes, 0, length);
+ xmlParser.setInput(inputStream, null);
+ TtmlSubtitle ttmlSubtitle = null;
+ ArrayDeque<TtmlNode> nodeStack = new ArrayDeque<>();
+ int unsupportedNodeDepth = 0;
+ int eventType = xmlParser.getEventType();
+ FrameAndTickRate frameAndTickRate = DEFAULT_FRAME_AND_TICK_RATE;
+ CellResolution cellResolution = DEFAULT_CELL_RESOLUTION;
+ TtsExtent ttsExtent = null;
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ TtmlNode parent = nodeStack.peek();
+ if (unsupportedNodeDepth == 0) {
+ String name = xmlParser.getName();
+ if (eventType == XmlPullParser.START_TAG) {
+ if (TtmlNode.TAG_TT.equals(name)) {
+ frameAndTickRate = parseFrameAndTickRates(xmlParser);
+ cellResolution = parseCellResolution(xmlParser, DEFAULT_CELL_RESOLUTION);
+ ttsExtent = parseTtsExtent(xmlParser);
+ }
+ if (!isSupportedTag(name)) {
+ Log.i(TAG, "Ignoring unsupported tag: " + xmlParser.getName());
+ unsupportedNodeDepth++;
+ } else if (TtmlNode.TAG_HEAD.equals(name)) {
+ parseHeader(xmlParser, globalStyles, cellResolution, ttsExtent, regionMap, imageMap);
+ } else {
+ try {
+ TtmlNode node = parseNode(xmlParser, parent, regionMap, frameAndTickRate);
+ nodeStack.push(node);
+ if (parent != null) {
+ parent.addChild(node);
+ }
+ } catch (SubtitleDecoderException e) {
+ Log.w(TAG, "Suppressing parser error", e);
+ // Treat the node (and by extension, all of its children) as unsupported.
+ unsupportedNodeDepth++;
+ }
+ }
+ } else if (eventType == XmlPullParser.TEXT) {
+ parent.addChild(TtmlNode.buildTextNode(xmlParser.getText()));
+ } else if (eventType == XmlPullParser.END_TAG) {
+ if (xmlParser.getName().equals(TtmlNode.TAG_TT)) {
+ ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap, imageMap);
+ }
+ nodeStack.pop();
+ }
+ } else {
+ if (eventType == XmlPullParser.START_TAG) {
+ unsupportedNodeDepth++;
+ } else if (eventType == XmlPullParser.END_TAG) {
+ unsupportedNodeDepth--;
+ }
+ }
+ xmlParser.next();
+ eventType = xmlParser.getEventType();
+ }
+ return ttmlSubtitle;
+ } catch (XmlPullParserException xppe) {
+ throw new SubtitleDecoderException("Unable to decode source", xppe);
+ } catch (IOException e) {
+ throw new IllegalStateException("Unexpected error when reading input.", e);
+ }
+ }
+
+ private FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser)
+ throws SubtitleDecoderException {
+ int frameRate = DEFAULT_FRAME_RATE;
+ String frameRateString = xmlParser.getAttributeValue(TTP, "frameRate");
+ if (frameRateString != null) {
+ frameRate = Integer.parseInt(frameRateString);
+ }
+
+ float frameRateMultiplier = 1;
+ String frameRateMultiplierString = xmlParser.getAttributeValue(TTP, "frameRateMultiplier");
+ if (frameRateMultiplierString != null) {
+ String[] parts = Util.split(frameRateMultiplierString, " ");
+ if (parts.length != 2) {
+ throw new SubtitleDecoderException("frameRateMultiplier doesn't have 2 parts");
+ }
+ float numerator = Integer.parseInt(parts[0]);
+ float denominator = Integer.parseInt(parts[1]);
+ frameRateMultiplier = numerator / denominator;
+ }
+
+ int subFrameRate = DEFAULT_FRAME_AND_TICK_RATE.subFrameRate;
+ String subFrameRateString = xmlParser.getAttributeValue(TTP, "subFrameRate");
+ if (subFrameRateString != null) {
+ subFrameRate = Integer.parseInt(subFrameRateString);
+ }
+
+ int tickRate = DEFAULT_FRAME_AND_TICK_RATE.tickRate;
+ String tickRateString = xmlParser.getAttributeValue(TTP, "tickRate");
+ if (tickRateString != null) {
+ tickRate = Integer.parseInt(tickRateString);
+ }
+ return new FrameAndTickRate(frameRate * frameRateMultiplier, subFrameRate, tickRate);
+ }
+
+ private CellResolution parseCellResolution(XmlPullParser xmlParser, CellResolution defaultValue)
+ throws SubtitleDecoderException {
+ String cellResolution = xmlParser.getAttributeValue(TTP, "cellResolution");
+ if (cellResolution == null) {
+ return defaultValue;
+ }
+
+ Matcher cellResolutionMatcher = CELL_RESOLUTION.matcher(cellResolution);
+ if (!cellResolutionMatcher.matches()) {
+ Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution);
+ return defaultValue;
+ }
+ try {
+ int columns = Integer.parseInt(cellResolutionMatcher.group(1));
+ int rows = Integer.parseInt(cellResolutionMatcher.group(2));
+ if (columns == 0 || rows == 0) {
+ throw new SubtitleDecoderException("Invalid cell resolution " + columns + " " + rows);
+ }
+ return new CellResolution(columns, rows);
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution);
+ return defaultValue;
+ }
+ }
+
+ private TtsExtent parseTtsExtent(XmlPullParser xmlParser) {
+ String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT);
+ if (ttsExtent == null) {
+ return null;
+ }
+
+ Matcher extentMatcher = PIXEL_COORDINATES.matcher(ttsExtent);
+ if (!extentMatcher.matches()) {
+ Log.w(TAG, "Ignoring non-pixel tts extent: " + ttsExtent);
+ return null;
+ }
+ try {
+ int width = Integer.parseInt(extentMatcher.group(1));
+ int height = Integer.parseInt(extentMatcher.group(2));
+ return new TtsExtent(width, height);
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring malformed tts extent: " + ttsExtent);
+ return null;
+ }
+ }
+
+ private Map<String, TtmlStyle> parseHeader(
+ XmlPullParser xmlParser,
+ Map<String, TtmlStyle> globalStyles,
+ CellResolution cellResolution,
+ TtsExtent ttsExtent,
+ Map<String, TtmlRegion> globalRegions,
+ Map<String, String> imageMap)
+ throws IOException, XmlPullParserException {
+ do {
+ xmlParser.next();
+ if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_STYLE)) {
+ String parentStyleId = XmlPullParserUtil.getAttributeValue(xmlParser, ATTR_STYLE);
+ TtmlStyle style = parseStyleAttributes(xmlParser, new TtmlStyle());
+ if (parentStyleId != null) {
+ for (String id : parseStyleIds(parentStyleId)) {
+ style.chain(globalStyles.get(id));
+ }
+ }
+ if (style.getId() != null) {
+ globalStyles.put(style.getId(), style);
+ }
+ } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_REGION)) {
+ TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution, ttsExtent);
+ if (ttmlRegion != null) {
+ globalRegions.put(ttmlRegion.id, ttmlRegion);
+ }
+ } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_METADATA)) {
+ parseMetadata(xmlParser, imageMap);
+ }
+ } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_HEAD));
+ return globalStyles;
+ }
+
+ private void parseMetadata(XmlPullParser xmlParser, Map<String, String> imageMap)
+ throws IOException, XmlPullParserException {
+ do {
+ xmlParser.next();
+ if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_IMAGE)) {
+ String id = XmlPullParserUtil.getAttributeValue(xmlParser, "id");
+ if (id != null) {
+ String encodedBitmapData = xmlParser.nextText();
+ imageMap.put(id, encodedBitmapData);
+ }
+ }
+ } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_METADATA));
+ }
+
+ /**
+ * Parses a region declaration.
+ *
+ * <p>Supports both percentage and pixel defined regions. In case of pixel defined regions the
+ * passed {@code ttsExtent} is used as a reference window to convert the pixel values to
+ * fractions. In case of missing tts:extent the pixel defined regions can't be parsed, and null is
+ * returned.
+ */
+ private TtmlRegion parseRegionAttributes(
+ XmlPullParser xmlParser, CellResolution cellResolution, TtsExtent ttsExtent) {
+ String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID);
+ if (regionId == null) {
+ return null;
+ }
+
+ float position;
+ float line;
+
+ String regionOrigin = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_ORIGIN);
+ if (regionOrigin != null) {
+ Matcher originPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin);
+ Matcher originPixelMatcher = PIXEL_COORDINATES.matcher(regionOrigin);
+ if (originPercentageMatcher.matches()) {
+ try {
+ position = Float.parseFloat(originPercentageMatcher.group(1)) / 100f;
+ line = Float.parseFloat(originPercentageMatcher.group(2)) / 100f;
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin);
+ return null;
+ }
+ } else if (originPixelMatcher.matches()) {
+ if (ttsExtent == null) {
+ Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin);
+ return null;
+ }
+ try {
+ int width = Integer.parseInt(originPixelMatcher.group(1));
+ int height = Integer.parseInt(originPixelMatcher.group(2));
+ // Convert pixel values to fractions.
+ position = width / (float) ttsExtent.width;
+ line = height / (float) ttsExtent.height;
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin);
+ return null;
+ }
+ } else {
+ Log.w(TAG, "Ignoring region with unsupported origin: " + regionOrigin);
+ return null;
+ }
+ } else {
+ Log.w(TAG, "Ignoring region without an origin");
+ return null;
+ // TODO: Should default to top left as below in this case, but need to fix
+ // https://github.com/google/ExoPlayer/issues/2953 first.
+ // Origin is omitted. Default to top left.
+ // position = 0;
+ // line = 0;
+ }
+
+ float width;
+ float height;
+ String regionExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT);
+ if (regionExtent != null) {
+ Matcher extentPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent);
+ Matcher extentPixelMatcher = PIXEL_COORDINATES.matcher(regionExtent);
+ if (extentPercentageMatcher.matches()) {
+ try {
+ width = Float.parseFloat(extentPercentageMatcher.group(1)) / 100f;
+ height = Float.parseFloat(extentPercentageMatcher.group(2)) / 100f;
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin);
+ return null;
+ }
+ } else if (extentPixelMatcher.matches()) {
+ if (ttsExtent == null) {
+ Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin);
+ return null;
+ }
+ try {
+ int extentWidth = Integer.parseInt(extentPixelMatcher.group(1));
+ int extentHeight = Integer.parseInt(extentPixelMatcher.group(2));
+ // Convert pixel values to fractions.
+ width = extentWidth / (float) ttsExtent.width;
+ height = extentHeight / (float) ttsExtent.height;
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin);
+ return null;
+ }
+ } else {
+ Log.w(TAG, "Ignoring region with unsupported extent: " + regionOrigin);
+ return null;
+ }
+ } else {
+ Log.w(TAG, "Ignoring region without an extent");
+ return null;
+ // TODO: Should default to extent of parent as below in this case, but need to fix
+ // https://github.com/google/ExoPlayer/issues/2953 first.
+ // Extent is omitted. Default to extent of parent.
+ // width = 1;
+ // height = 1;
+ }
+
+ @Cue.AnchorType int lineAnchor = Cue.ANCHOR_TYPE_START;
+ String displayAlign = XmlPullParserUtil.getAttributeValue(xmlParser,
+ TtmlNode.ATTR_TTS_DISPLAY_ALIGN);
+ if (displayAlign != null) {
+ switch (Util.toLowerInvariant(displayAlign)) {
+ case "center":
+ lineAnchor = Cue.ANCHOR_TYPE_MIDDLE;
+ line += height / 2;
+ break;
+ case "after":
+ lineAnchor = Cue.ANCHOR_TYPE_END;
+ line += height;
+ break;
+ default:
+ // Default "before" case. Do nothing.
+ break;
+ }
+ }
+
+ float regionTextHeight = 1.0f / cellResolution.rows;
+ return new TtmlRegion(
+ regionId,
+ position,
+ line,
+ /* lineType= */ Cue.LINE_TYPE_FRACTION,
+ lineAnchor,
+ width,
+ height,
+ /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING,
+ /* textSize= */ regionTextHeight);
+ }
+
+ private String[] parseStyleIds(String parentStyleIds) {
+ parentStyleIds = parentStyleIds.trim();
+ return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+");
+ }
+
+ private TtmlStyle parseStyleAttributes(XmlPullParser parser, TtmlStyle style) {
+ int attributeCount = parser.getAttributeCount();
+ for (int i = 0; i < attributeCount; i++) {
+ String attributeValue = parser.getAttributeValue(i);
+ switch (parser.getAttributeName(i)) {
+ case TtmlNode.ATTR_ID:
+ if (TtmlNode.TAG_STYLE.equals(parser.getName())) {
+ style = createIfNull(style).setId(attributeValue);
+ }
+ break;
+ case TtmlNode.ATTR_TTS_BACKGROUND_COLOR:
+ style = createIfNull(style);
+ try {
+ style.setBackgroundColor(ColorParser.parseTtmlColor(attributeValue));
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Failed parsing background value: " + attributeValue);
+ }
+ break;
+ case TtmlNode.ATTR_TTS_COLOR:
+ style = createIfNull(style);
+ try {
+ style.setFontColor(ColorParser.parseTtmlColor(attributeValue));
+ } catch (IllegalArgumentException e) {
+ Log.w(TAG, "Failed parsing color value: " + attributeValue);
+ }
+ break;
+ case TtmlNode.ATTR_TTS_FONT_FAMILY:
+ style = createIfNull(style).setFontFamily(attributeValue);
+ break;
+ case TtmlNode.ATTR_TTS_FONT_SIZE:
+ try {
+ style = createIfNull(style);
+ parseFontSize(attributeValue, style);
+ } catch (SubtitleDecoderException e) {
+ Log.w(TAG, "Failed parsing fontSize value: " + attributeValue);
+ }
+ break;
+ case TtmlNode.ATTR_TTS_FONT_WEIGHT:
+ style = createIfNull(style).setBold(
+ TtmlNode.BOLD.equalsIgnoreCase(attributeValue));
+ break;
+ case TtmlNode.ATTR_TTS_FONT_STYLE:
+ style = createIfNull(style).setItalic(
+ TtmlNode.ITALIC.equalsIgnoreCase(attributeValue));
+ break;
+ case TtmlNode.ATTR_TTS_TEXT_ALIGN:
+ switch (Util.toLowerInvariant(attributeValue)) {
+ case TtmlNode.LEFT:
+ style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_NORMAL);
+ break;
+ case TtmlNode.START:
+ style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_NORMAL);
+ break;
+ case TtmlNode.RIGHT:
+ style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_OPPOSITE);
+ break;
+ case TtmlNode.END:
+ style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_OPPOSITE);
+ break;
+ case TtmlNode.CENTER:
+ style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_CENTER);
+ break;
+ }
+ break;
+ case TtmlNode.ATTR_TTS_TEXT_DECORATION:
+ switch (Util.toLowerInvariant(attributeValue)) {
+ case TtmlNode.LINETHROUGH:
+ style = createIfNull(style).setLinethrough(true);
+ break;
+ case TtmlNode.NO_LINETHROUGH:
+ style = createIfNull(style).setLinethrough(false);
+ break;
+ case TtmlNode.UNDERLINE:
+ style = createIfNull(style).setUnderline(true);
+ break;
+ case TtmlNode.NO_UNDERLINE:
+ style = createIfNull(style).setUnderline(false);
+ break;
+ }
+ break;
+ default:
+ // ignore
+ break;
+ }
+ }
+ return style;
+ }
+
+ private TtmlStyle createIfNull(TtmlStyle style) {
+ return style == null ? new TtmlStyle() : style;
+ }
+
+ private TtmlNode parseNode(XmlPullParser parser, TtmlNode parent,
+ Map<String, TtmlRegion> regionMap, FrameAndTickRate frameAndTickRate)
+ throws SubtitleDecoderException {
+ long duration = C.TIME_UNSET;
+ long startTime = C.TIME_UNSET;
+ long endTime = C.TIME_UNSET;
+ String regionId = TtmlNode.ANONYMOUS_REGION_ID;
+ String imageId = null;
+ String[] styleIds = null;
+ int attributeCount = parser.getAttributeCount();
+ TtmlStyle style = parseStyleAttributes(parser, null);
+ for (int i = 0; i < attributeCount; i++) {
+ String attr = parser.getAttributeName(i);
+ String value = parser.getAttributeValue(i);
+ switch (attr) {
+ case ATTR_BEGIN:
+ startTime = parseTimeExpression(value, frameAndTickRate);
+ break;
+ case ATTR_END:
+ endTime = parseTimeExpression(value, frameAndTickRate);
+ break;
+ case ATTR_DURATION:
+ duration = parseTimeExpression(value, frameAndTickRate);
+ break;
+ case ATTR_STYLE:
+ // IDREFS: potentially multiple space delimited ids
+ String[] ids = parseStyleIds(value);
+ if (ids.length > 0) {
+ styleIds = ids;
+ }
+ break;
+ case ATTR_REGION:
+ if (regionMap.containsKey(value)) {
+ // If the region has not been correctly declared or does not define a position, we use
+ // the anonymous region.
+ regionId = value;
+ }
+ break;
+ case ATTR_IMAGE:
+ // Parse URI reference only if refers to an element in the same document (it must start
+ // with '#'). Resolving URIs from external sources is not supported.
+ if (value.startsWith("#")) {
+ imageId = value.substring(1);
+ }
+ break;
+ default:
+ // Do nothing.
+ break;
+ }
+ }
+ if (parent != null && parent.startTimeUs != C.TIME_UNSET) {
+ if (startTime != C.TIME_UNSET) {
+ startTime += parent.startTimeUs;
+ }
+ if (endTime != C.TIME_UNSET) {
+ endTime += parent.startTimeUs;
+ }
+ }
+ if (endTime == C.TIME_UNSET) {
+ if (duration != C.TIME_UNSET) {
+ // Infer the end time from the duration.
+ endTime = startTime + duration;
+ } else if (parent != null && parent.endTimeUs != C.TIME_UNSET) {
+ // If the end time remains unspecified, then it should be inherited from the parent.
+ endTime = parent.endTimeUs;
+ }
+ }
+ return TtmlNode.buildNode(
+ parser.getName(), startTime, endTime, style, styleIds, regionId, imageId);
+ }
+
+ private static boolean isSupportedTag(String tag) {
+ return tag.equals(TtmlNode.TAG_TT)
+ || tag.equals(TtmlNode.TAG_HEAD)
+ || tag.equals(TtmlNode.TAG_BODY)
+ || tag.equals(TtmlNode.TAG_DIV)
+ || tag.equals(TtmlNode.TAG_P)
+ || tag.equals(TtmlNode.TAG_SPAN)
+ || tag.equals(TtmlNode.TAG_BR)
+ || tag.equals(TtmlNode.TAG_STYLE)
+ || tag.equals(TtmlNode.TAG_STYLING)
+ || tag.equals(TtmlNode.TAG_LAYOUT)
+ || tag.equals(TtmlNode.TAG_REGION)
+ || tag.equals(TtmlNode.TAG_METADATA)
+ || tag.equals(TtmlNode.TAG_IMAGE)
+ || tag.equals(TtmlNode.TAG_DATA)
+ || tag.equals(TtmlNode.TAG_INFORMATION);
+ }
+
+ private static void parseFontSize(String expression, TtmlStyle out) throws
+ SubtitleDecoderException {
+ String[] expressions = Util.split(expression, "\\s+");
+ Matcher matcher;
+ if (expressions.length == 1) {
+ matcher = FONT_SIZE.matcher(expression);
+ } else if (expressions.length == 2){
+ matcher = FONT_SIZE.matcher(expressions[1]);
+ Log.w(TAG, "Multiple values in fontSize attribute. Picking the second value for vertical font"
+ + " size and ignoring the first.");
+ } else {
+ throw new SubtitleDecoderException("Invalid number of entries for fontSize: "
+ + expressions.length + ".");
+ }
+
+ if (matcher.matches()) {
+ String unit = matcher.group(3);
+ switch (unit) {
+ case "px":
+ out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PIXEL);
+ break;
+ case "em":
+ out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_EM);
+ break;
+ case "%":
+ out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PERCENT);
+ break;
+ default:
+ throw new SubtitleDecoderException("Invalid unit for fontSize: '" + unit + "'.");
+ }
+ out.setFontSize(Float.valueOf(matcher.group(1)));
+ } else {
+ throw new SubtitleDecoderException("Invalid expression for fontSize: '" + expression + "'.");
+ }
+ }
+
+ /**
+ * Parses a time expression, returning the parsed timestamp.
+ * <p>
+ * For the format of a time expression, see:
+ * <a href="http://www.w3.org/TR/ttaf1-dfxp/#timing-value-timeExpression">timeExpression</a>
+ *
+ * @param time A string that includes the time expression.
+ * @param frameAndTickRate The effective frame and tick rates of the stream.
+ * @return The parsed timestamp in microseconds.
+ * @throws SubtitleDecoderException If the given string does not contain a valid time expression.
+ */
+ private static long parseTimeExpression(String time, FrameAndTickRate frameAndTickRate)
+ throws SubtitleDecoderException {
+ Matcher matcher = CLOCK_TIME.matcher(time);
+ if (matcher.matches()) {
+ String hours = matcher.group(1);
+ double durationSeconds = Long.parseLong(hours) * 3600;
+ String minutes = matcher.group(2);
+ durationSeconds += Long.parseLong(minutes) * 60;
+ String seconds = matcher.group(3);
+ durationSeconds += Long.parseLong(seconds);
+ String fraction = matcher.group(4);
+ durationSeconds += (fraction != null) ? Double.parseDouble(fraction) : 0;
+ String frames = matcher.group(5);
+ durationSeconds += (frames != null)
+ ? Long.parseLong(frames) / frameAndTickRate.effectiveFrameRate : 0;
+ String subframes = matcher.group(6);
+ durationSeconds += (subframes != null)
+ ? ((double) Long.parseLong(subframes)) / frameAndTickRate.subFrameRate
+ / frameAndTickRate.effectiveFrameRate
+ : 0;
+ return (long) (durationSeconds * C.MICROS_PER_SECOND);
+ }
+ matcher = OFFSET_TIME.matcher(time);
+ if (matcher.matches()) {
+ String timeValue = matcher.group(1);
+ double offsetSeconds = Double.parseDouble(timeValue);
+ String unit = matcher.group(2);
+ switch (unit) {
+ case "h":
+ offsetSeconds *= 3600;
+ break;
+ case "m":
+ offsetSeconds *= 60;
+ break;
+ case "s":
+ // Do nothing.
+ break;
+ case "ms":
+ offsetSeconds /= 1000;
+ break;
+ case "f":
+ offsetSeconds /= frameAndTickRate.effectiveFrameRate;
+ break;
+ case "t":
+ offsetSeconds /= frameAndTickRate.tickRate;
+ break;
+ }
+ return (long) (offsetSeconds * C.MICROS_PER_SECOND);
+ }
+ throw new SubtitleDecoderException("Malformed time expression: " + time);
+ }
+
+ private static final class FrameAndTickRate {
+ final float effectiveFrameRate;
+ final int subFrameRate;
+ final int tickRate;
+
+ FrameAndTickRate(float effectiveFrameRate, int subFrameRate, int tickRate) {
+ this.effectiveFrameRate = effectiveFrameRate;
+ this.subFrameRate = subFrameRate;
+ this.tickRate = tickRate;
+ }
+ }
+
+ /** Represents the cell resolution for a TTML file. */
+ private static final class CellResolution {
+ final int columns;
+ final int rows;
+
+ CellResolution(int columns, int rows) {
+ this.columns = columns;
+ this.rows = rows;
+ }
+ }
+
+ /** Represents the tts:extent for a TTML file. */
+ private static final class TtsExtent {
+ final int width;
+ final int height;
+
+ TtsExtent(int width, int height) {
+ this.width = width;
+ this.height = height;
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java
new file mode 100644
index 0000000000..16d0f28f6b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java
@@ -0,0 +1,399 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.text.SpannableStringBuilder;
+import android.util.Base64;
+import android.util.Pair;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+/**
+ * A package internal representation of TTML node.
+ */
+/* package */ final class TtmlNode {
+
+ public static final String TAG_TT = "tt";
+ public static final String TAG_HEAD = "head";
+ public static final String TAG_BODY = "body";
+ public static final String TAG_DIV = "div";
+ public static final String TAG_P = "p";
+ public static final String TAG_SPAN = "span";
+ public static final String TAG_BR = "br";
+ public static final String TAG_STYLE = "style";
+ public static final String TAG_STYLING = "styling";
+ public static final String TAG_LAYOUT = "layout";
+ public static final String TAG_REGION = "region";
+ public static final String TAG_METADATA = "metadata";
+ public static final String TAG_IMAGE = "image";
+ public static final String TAG_DATA = "data";
+ public static final String TAG_INFORMATION = "information";
+
+ public static final String ANONYMOUS_REGION_ID = "";
+ public static final String ATTR_ID = "id";
+ public static final String ATTR_TTS_ORIGIN = "origin";
+ public static final String ATTR_TTS_EXTENT = "extent";
+ public static final String ATTR_TTS_DISPLAY_ALIGN = "displayAlign";
+ public static final String ATTR_TTS_BACKGROUND_COLOR = "backgroundColor";
+ public static final String ATTR_TTS_FONT_STYLE = "fontStyle";
+ public static final String ATTR_TTS_FONT_SIZE = "fontSize";
+ public static final String ATTR_TTS_FONT_FAMILY = "fontFamily";
+ public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight";
+ public static final String ATTR_TTS_COLOR = "color";
+ public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration";
+ public static final String ATTR_TTS_TEXT_ALIGN = "textAlign";
+
+ public static final String LINETHROUGH = "linethrough";
+ public static final String NO_LINETHROUGH = "nolinethrough";
+ public static final String UNDERLINE = "underline";
+ public static final String NO_UNDERLINE = "nounderline";
+ public static final String ITALIC = "italic";
+ public static final String BOLD = "bold";
+
+ public static final String LEFT = "left";
+ public static final String CENTER = "center";
+ public static final String RIGHT = "right";
+ public static final String START = "start";
+ public static final String END = "end";
+
+ @Nullable public final String tag;
+ @Nullable public final String text;
+ public final boolean isTextNode;
+ public final long startTimeUs;
+ public final long endTimeUs;
+ @Nullable public final TtmlStyle style;
+ @Nullable private final String[] styleIds;
+ public final String regionId;
+ @Nullable public final String imageId;
+
+ private final HashMap<String, Integer> nodeStartsByRegion;
+ private final HashMap<String, Integer> nodeEndsByRegion;
+
+ private List<TtmlNode> children;
+
+ public static TtmlNode buildTextNode(String text) {
+ return new TtmlNode(
+ /* tag= */ null,
+ TtmlRenderUtil.applyTextElementSpacePolicy(text),
+ /* startTimeUs= */ C.TIME_UNSET,
+ /* endTimeUs= */ C.TIME_UNSET,
+ /* style= */ null,
+ /* styleIds= */ null,
+ ANONYMOUS_REGION_ID,
+ /* imageId= */ null);
+ }
+
+ public static TtmlNode buildNode(
+ @Nullable String tag,
+ long startTimeUs,
+ long endTimeUs,
+ @Nullable TtmlStyle style,
+ @Nullable String[] styleIds,
+ String regionId,
+ @Nullable String imageId) {
+ return new TtmlNode(
+ tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId);
+ }
+
+ private TtmlNode(
+ @Nullable String tag,
+ @Nullable String text,
+ long startTimeUs,
+ long endTimeUs,
+ @Nullable TtmlStyle style,
+ @Nullable String[] styleIds,
+ String regionId,
+ @Nullable String imageId) {
+ this.tag = tag;
+ this.text = text;
+ this.imageId = imageId;
+ this.style = style;
+ this.styleIds = styleIds;
+ this.isTextNode = text != null;
+ this.startTimeUs = startTimeUs;
+ this.endTimeUs = endTimeUs;
+ this.regionId = Assertions.checkNotNull(regionId);
+ nodeStartsByRegion = new HashMap<>();
+ nodeEndsByRegion = new HashMap<>();
+ }
+
+ public boolean isActive(long timeUs) {
+ return (startTimeUs == C.TIME_UNSET && endTimeUs == C.TIME_UNSET)
+ || (startTimeUs <= timeUs && endTimeUs == C.TIME_UNSET)
+ || (startTimeUs == C.TIME_UNSET && timeUs < endTimeUs)
+ || (startTimeUs <= timeUs && timeUs < endTimeUs);
+ }
+
+ public void addChild(TtmlNode child) {
+ if (children == null) {
+ children = new ArrayList<>();
+ }
+ children.add(child);
+ }
+
+ public TtmlNode getChild(int index) {
+ if (children == null) {
+ throw new IndexOutOfBoundsException();
+ }
+ return children.get(index);
+ }
+
+ public int getChildCount() {
+ return children == null ? 0 : children.size();
+ }
+
+ public long[] getEventTimesUs() {
+ TreeSet<Long> eventTimeSet = new TreeSet<>();
+ getEventTimes(eventTimeSet, false);
+ long[] eventTimes = new long[eventTimeSet.size()];
+ int i = 0;
+ for (long eventTimeUs : eventTimeSet) {
+ eventTimes[i++] = eventTimeUs;
+ }
+ return eventTimes;
+ }
+
+ private void getEventTimes(TreeSet<Long> out, boolean descendsPNode) {
+ boolean isPNode = TAG_P.equals(tag);
+ boolean isDivNode = TAG_DIV.equals(tag);
+ if (descendsPNode || isPNode || (isDivNode && imageId != null)) {
+ if (startTimeUs != C.TIME_UNSET) {
+ out.add(startTimeUs);
+ }
+ if (endTimeUs != C.TIME_UNSET) {
+ out.add(endTimeUs);
+ }
+ }
+ if (children == null) {
+ return;
+ }
+ for (int i = 0; i < children.size(); i++) {
+ children.get(i).getEventTimes(out, descendsPNode || isPNode);
+ }
+ }
+
+ public String[] getStyleIds() {
+ return styleIds;
+ }
+
+ public List<Cue> getCues(
+ long timeUs,
+ Map<String, TtmlStyle> globalStyles,
+ Map<String, TtmlRegion> regionMap,
+ Map<String, String> imageMap) {
+
+ List<Pair<String, String>> regionImageOutputs = new ArrayList<>();
+ traverseForImage(timeUs, regionId, regionImageOutputs);
+
+ TreeMap<String, SpannableStringBuilder> regionTextOutputs = new TreeMap<>();
+ traverseForText(timeUs, false, regionId, regionTextOutputs);
+ traverseForStyle(timeUs, globalStyles, regionTextOutputs);
+
+ List<Cue> cues = new ArrayList<>();
+
+ // Create image based cues.
+ for (Pair<String, String> regionImagePair : regionImageOutputs) {
+ String encodedBitmapData = imageMap.get(regionImagePair.second);
+ if (encodedBitmapData == null) {
+ // Image reference points to an invalid image. Do nothing.
+ continue;
+ }
+
+ byte[] bitmapData = Base64.decode(encodedBitmapData, Base64.DEFAULT);
+ Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, /* offset= */ 0, bitmapData.length);
+ TtmlRegion region = regionMap.get(regionImagePair.first);
+
+ cues.add(
+ new Cue(
+ bitmap,
+ region.position,
+ Cue.ANCHOR_TYPE_START,
+ region.line,
+ region.lineAnchor,
+ region.width,
+ region.height));
+ }
+
+ // Create text based cues.
+ for (Entry<String, SpannableStringBuilder> entry : regionTextOutputs.entrySet()) {
+ TtmlRegion region = regionMap.get(entry.getKey());
+ cues.add(
+ new Cue(
+ cleanUpText(entry.getValue()),
+ /* textAlignment= */ null,
+ region.line,
+ region.lineType,
+ region.lineAnchor,
+ region.position,
+ /* positionAnchor= */ Cue.TYPE_UNSET,
+ region.width,
+ region.textSizeType,
+ region.textSize));
+ }
+
+ return cues;
+ }
+
+ private void traverseForImage(
+ long timeUs, String inheritedRegion, List<Pair<String, String>> regionImageList) {
+ String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId;
+ if (isActive(timeUs) && TAG_DIV.equals(tag) && imageId != null) {
+ regionImageList.add(new Pair<>(resolvedRegionId, imageId));
+ return;
+ }
+ for (int i = 0; i < getChildCount(); ++i) {
+ getChild(i).traverseForImage(timeUs, resolvedRegionId, regionImageList);
+ }
+ }
+
+ private void traverseForText(
+ long timeUs,
+ boolean descendsPNode,
+ String inheritedRegion,
+ Map<String, SpannableStringBuilder> regionOutputs) {
+ nodeStartsByRegion.clear();
+ nodeEndsByRegion.clear();
+ if (TAG_METADATA.equals(tag)) {
+ // Ignore metadata tag.
+ return;
+ }
+
+ String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId;
+
+ if (isTextNode && descendsPNode) {
+ getRegionOutput(resolvedRegionId, regionOutputs).append(text);
+ } else if (TAG_BR.equals(tag) && descendsPNode) {
+ getRegionOutput(resolvedRegionId, regionOutputs).append('\n');
+ } else if (isActive(timeUs)) {
+ // This is a container node, which can contain zero or more children.
+ for (Entry<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) {
+ nodeStartsByRegion.put(entry.getKey(), entry.getValue().length());
+ }
+
+ boolean isPNode = TAG_P.equals(tag);
+ for (int i = 0; i < getChildCount(); i++) {
+ getChild(i).traverseForText(timeUs, descendsPNode || isPNode, resolvedRegionId,
+ regionOutputs);
+ }
+ if (isPNode) {
+ TtmlRenderUtil.endParagraph(getRegionOutput(resolvedRegionId, regionOutputs));
+ }
+
+ for (Entry<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) {
+ nodeEndsByRegion.put(entry.getKey(), entry.getValue().length());
+ }
+ }
+ }
+
+ private static SpannableStringBuilder getRegionOutput(
+ String resolvedRegionId, Map<String, SpannableStringBuilder> regionOutputs) {
+ if (!regionOutputs.containsKey(resolvedRegionId)) {
+ regionOutputs.put(resolvedRegionId, new SpannableStringBuilder());
+ }
+ return regionOutputs.get(resolvedRegionId);
+ }
+
+ private void traverseForStyle(
+ long timeUs,
+ Map<String, TtmlStyle> globalStyles,
+ Map<String, SpannableStringBuilder> regionOutputs) {
+ if (!isActive(timeUs)) {
+ return;
+ }
+ for (Entry<String, Integer> entry : nodeEndsByRegion.entrySet()) {
+ String regionId = entry.getKey();
+ int start = nodeStartsByRegion.containsKey(regionId) ? nodeStartsByRegion.get(regionId) : 0;
+ int end = entry.getValue();
+ if (start != end) {
+ SpannableStringBuilder regionOutput = regionOutputs.get(regionId);
+ applyStyleToOutput(globalStyles, regionOutput, start, end);
+ }
+ }
+ for (int i = 0; i < getChildCount(); ++i) {
+ getChild(i).traverseForStyle(timeUs, globalStyles, regionOutputs);
+ }
+ }
+
+ private void applyStyleToOutput(
+ Map<String, TtmlStyle> globalStyles,
+ SpannableStringBuilder regionOutput,
+ int start,
+ int end) {
+ TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles);
+ if (resolvedStyle != null) {
+ TtmlRenderUtil.applyStylesToSpan(regionOutput, start, end, resolvedStyle);
+ }
+ }
+
+ private SpannableStringBuilder cleanUpText(SpannableStringBuilder builder) {
+ // Having joined the text elements, we need to do some final cleanup on the result.
+ // 1. Collapse multiple consecutive spaces into a single space.
+ int builderLength = builder.length();
+ for (int i = 0; i < builderLength; i++) {
+ if (builder.charAt(i) == ' ') {
+ int j = i + 1;
+ while (j < builder.length() && builder.charAt(j) == ' ') {
+ j++;
+ }
+ int spacesToDelete = j - (i + 1);
+ if (spacesToDelete > 0) {
+ builder.delete(i, i + spacesToDelete);
+ builderLength -= spacesToDelete;
+ }
+ }
+ }
+ // 2. Remove any spaces from the start of each line.
+ if (builderLength > 0 && builder.charAt(0) == ' ') {
+ builder.delete(0, 1);
+ builderLength--;
+ }
+ for (int i = 0; i < builderLength - 1; i++) {
+ if (builder.charAt(i) == '\n' && builder.charAt(i + 1) == ' ') {
+ builder.delete(i + 1, i + 2);
+ builderLength--;
+ }
+ }
+ // 3. Remove any spaces from the end of each line.
+ if (builderLength > 0 && builder.charAt(builderLength - 1) == ' ') {
+ builder.delete(builderLength - 1, builderLength);
+ builderLength--;
+ }
+ for (int i = 0; i < builderLength - 1; i++) {
+ if (builder.charAt(i) == ' ' && builder.charAt(i + 1) == '\n') {
+ builder.delete(i, i + 1);
+ builderLength--;
+ }
+ }
+ // 4. Trim a trailing newline, if there is one.
+ if (builderLength > 0 && builder.charAt(builderLength - 1) == '\n') {
+ builder.delete(builderLength - 1, builderLength);
+ /*builderLength--;*/
+ }
+ return builder;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java
new file mode 100644
index 0000000000..d14e547d49
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java
@@ -0,0 +1,69 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+
+/**
+ * Represents a TTML Region.
+ */
+/* package */ final class TtmlRegion {
+
+ public final String id;
+ public final float position;
+ public final float line;
+ public final @Cue.LineType int lineType;
+ public final @Cue.AnchorType int lineAnchor;
+ public final float width;
+ public final float height;
+ public final @Cue.TextSizeType int textSizeType;
+ public final float textSize;
+
+ public TtmlRegion(String id) {
+ this(
+ id,
+ /* position= */ Cue.DIMEN_UNSET,
+ /* line= */ Cue.DIMEN_UNSET,
+ /* lineType= */ Cue.TYPE_UNSET,
+ /* lineAnchor= */ Cue.TYPE_UNSET,
+ /* width= */ Cue.DIMEN_UNSET,
+ /* height= */ Cue.DIMEN_UNSET,
+ /* textSizeType= */ Cue.TYPE_UNSET,
+ /* textSize= */ Cue.DIMEN_UNSET);
+ }
+
+ public TtmlRegion(
+ String id,
+ float position,
+ float line,
+ @Cue.LineType int lineType,
+ @Cue.AnchorType int lineAnchor,
+ float width,
+ float height,
+ int textSizeType,
+ float textSize) {
+ this.id = id;
+ this.position = position;
+ this.line = line;
+ this.lineType = lineType;
+ this.lineAnchor = lineAnchor;
+ this.width = width;
+ this.height = height;
+ this.textSizeType = textSizeType;
+ this.textSize = textSize;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java
new file mode 100644
index 0000000000..f2387b6282
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java
@@ -0,0 +1,151 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.AlignmentSpan;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.UnderlineSpan;
+import java.util.Map;
+
+/**
+ * Package internal utility class to render styled <code>TtmlNode</code>s.
+ */
+/* package */ final class TtmlRenderUtil {
+
+ public static TtmlStyle resolveStyle(TtmlStyle style, String[] styleIds,
+ Map<String, TtmlStyle> globalStyles) {
+ if (style == null && styleIds == null) {
+ // No styles at all.
+ return null;
+ } else if (style == null && styleIds.length == 1) {
+ // Only one single referential style present.
+ return globalStyles.get(styleIds[0]);
+ } else if (style == null && styleIds.length > 1) {
+ // Only multiple referential styles present.
+ TtmlStyle chainedStyle = new TtmlStyle();
+ for (String id : styleIds) {
+ chainedStyle.chain(globalStyles.get(id));
+ }
+ return chainedStyle;
+ } else if (style != null && styleIds != null && styleIds.length == 1) {
+ // Merge a single referential style into inline style.
+ return style.chain(globalStyles.get(styleIds[0]));
+ } else if (style != null && styleIds != null && styleIds.length > 1) {
+ // Merge multiple referential styles into inline style.
+ for (String id : styleIds) {
+ style.chain(globalStyles.get(id));
+ }
+ return style;
+ }
+ // Only inline styles available.
+ return style;
+ }
+
+ public static void applyStylesToSpan(SpannableStringBuilder builder,
+ int start, int end, TtmlStyle style) {
+
+ if (style.getStyle() != TtmlStyle.UNSPECIFIED) {
+ builder.setSpan(new StyleSpan(style.getStyle()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.isLinethrough()) {
+ builder.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.isUnderline()) {
+ builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.hasFontColor()) {
+ builder.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.hasBackgroundColor()) {
+ builder.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.getFontFamily() != null) {
+ builder.setSpan(new TypefaceSpan(style.getFontFamily()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.getTextAlign() != null) {
+ builder.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ switch (style.getFontSizeUnit()) {
+ case TtmlStyle.FONT_SIZE_UNIT_PIXEL:
+ builder.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TtmlStyle.FONT_SIZE_UNIT_EM:
+ builder.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TtmlStyle.FONT_SIZE_UNIT_PERCENT:
+ builder.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TtmlStyle.UNSPECIFIED:
+ // Do nothing.
+ break;
+ }
+ }
+
+ /**
+ * Called when the end of a paragraph is encountered. Adds a newline if there are one or more
+ * non-space characters since the previous newline.
+ *
+ * @param builder The builder.
+ */
+ /* package */ static void endParagraph(SpannableStringBuilder builder) {
+ int position = builder.length() - 1;
+ while (position >= 0 && builder.charAt(position) == ' ') {
+ position--;
+ }
+ if (position >= 0 && builder.charAt(position) != '\n') {
+ builder.append('\n');
+ }
+ }
+
+ /**
+ * Applies the appropriate space policy to the given text element.
+ *
+ * @param in The text element to which the policy should be applied.
+ * @return The result of applying the policy to the text element.
+ */
+ /* package */ static String applyTextElementSpacePolicy(String in) {
+ // Removes carriage return followed by line feed. See: http://www.w3.org/TR/xml/#sec-line-ends
+ String out = in.replaceAll("\r\n", "\n");
+ // Apply suppress-at-line-break="auto" and
+ // white-space-treatment="ignore-if-surrounding-linefeed"
+ out = out.replaceAll(" *\n *", "\n");
+ // Apply linefeed-treatment="treat-as-space"
+ out = out.replaceAll("\n", " ");
+ // Apply white-space-collapse="true"
+ out = out.replaceAll("[ \t\\x0B\f\r]+", " ");
+ return out;
+ }
+
+ private TtmlRenderUtil() {}
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java
new file mode 100644
index 0000000000..57faaecb69
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java
@@ -0,0 +1,268 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import android.graphics.Typeface;
+import android.text.Layout;
+import androidx.annotation.IntDef;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+/**
+ * Style object of a <code>TtmlNode</code>
+ */
+/* package */ final class TtmlStyle {
+
+ public static final int UNSPECIFIED = -1;
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {UNSPECIFIED, STYLE_NORMAL, STYLE_BOLD, STYLE_ITALIC, STYLE_BOLD_ITALIC})
+ public @interface StyleFlags {}
+
+ public static final int STYLE_NORMAL = Typeface.NORMAL;
+ public static final int STYLE_BOLD = Typeface.BOLD;
+ public static final int STYLE_ITALIC = Typeface.ITALIC;
+ public static final int STYLE_BOLD_ITALIC = Typeface.BOLD_ITALIC;
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({UNSPECIFIED, FONT_SIZE_UNIT_PIXEL, FONT_SIZE_UNIT_EM, FONT_SIZE_UNIT_PERCENT})
+ public @interface FontSizeUnit {}
+
+ public static final int FONT_SIZE_UNIT_PIXEL = 1;
+ public static final int FONT_SIZE_UNIT_EM = 2;
+ public static final int FONT_SIZE_UNIT_PERCENT = 3;
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({UNSPECIFIED, OFF, ON})
+ private @interface OptionalBoolean {}
+
+ private static final int OFF = 0;
+ private static final int ON = 1;
+
+ private String fontFamily;
+ private int fontColor;
+ private boolean hasFontColor;
+ private int backgroundColor;
+ private boolean hasBackgroundColor;
+ @OptionalBoolean private int linethrough;
+ @OptionalBoolean private int underline;
+ @OptionalBoolean private int bold;
+ @OptionalBoolean private int italic;
+ @FontSizeUnit private int fontSizeUnit;
+ private float fontSize;
+ private String id;
+ private TtmlStyle inheritableStyle;
+ private Layout.Alignment textAlign;
+
+ public TtmlStyle() {
+ linethrough = UNSPECIFIED;
+ underline = UNSPECIFIED;
+ bold = UNSPECIFIED;
+ italic = UNSPECIFIED;
+ fontSizeUnit = UNSPECIFIED;
+ }
+
+ /**
+ * Returns the style or {@link #UNSPECIFIED} when no style information is given.
+ *
+ * @return {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link #STYLE_BOLD}, {@link #STYLE_BOLD}
+ * or {@link #STYLE_BOLD_ITALIC}.
+ */
+ @StyleFlags public int getStyle() {
+ if (bold == UNSPECIFIED && italic == UNSPECIFIED) {
+ return UNSPECIFIED;
+ }
+ return (bold == ON ? STYLE_BOLD : STYLE_NORMAL)
+ | (italic == ON ? STYLE_ITALIC : STYLE_NORMAL);
+ }
+
+ public boolean isLinethrough() {
+ return linethrough == ON;
+ }
+
+ public TtmlStyle setLinethrough(boolean linethrough) {
+ Assertions.checkState(inheritableStyle == null);
+ this.linethrough = linethrough ? ON : OFF;
+ return this;
+ }
+
+ public boolean isUnderline() {
+ return underline == ON;
+ }
+
+ public TtmlStyle setUnderline(boolean underline) {
+ Assertions.checkState(inheritableStyle == null);
+ this.underline = underline ? ON : OFF;
+ return this;
+ }
+
+ public TtmlStyle setBold(boolean bold) {
+ Assertions.checkState(inheritableStyle == null);
+ this.bold = bold ? ON : OFF;
+ return this;
+ }
+
+ public TtmlStyle setItalic(boolean italic) {
+ Assertions.checkState(inheritableStyle == null);
+ this.italic = italic ? ON : OFF;
+ return this;
+ }
+
+ public String getFontFamily() {
+ return fontFamily;
+ }
+
+ public TtmlStyle setFontFamily(String fontFamily) {
+ Assertions.checkState(inheritableStyle == null);
+ this.fontFamily = fontFamily;
+ return this;
+ }
+
+ public int getFontColor() {
+ if (!hasFontColor) {
+ throw new IllegalStateException("Font color has not been defined.");
+ }
+ return fontColor;
+ }
+
+ public TtmlStyle setFontColor(int fontColor) {
+ Assertions.checkState(inheritableStyle == null);
+ this.fontColor = fontColor;
+ hasFontColor = true;
+ return this;
+ }
+
+ public boolean hasFontColor() {
+ return hasFontColor;
+ }
+
+ public int getBackgroundColor() {
+ if (!hasBackgroundColor) {
+ throw new IllegalStateException("Background color has not been defined.");
+ }
+ return backgroundColor;
+ }
+
+ public TtmlStyle setBackgroundColor(int backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ hasBackgroundColor = true;
+ return this;
+ }
+
+ public boolean hasBackgroundColor() {
+ return hasBackgroundColor;
+ }
+
+ /**
+ * Inherits from an ancestor style. Properties like <i>tts:backgroundColor</i> which
+ * are not inheritable are not inherited as well as properties which are already set locally
+ * are never overridden.
+ *
+ * @param ancestor the ancestor style to inherit from
+ */
+ public TtmlStyle inherit(TtmlStyle ancestor) {
+ return inherit(ancestor, false);
+ }
+
+ /**
+ * Chains this style to referential style. Local properties which are already set
+ * are never overridden.
+ *
+ * @param ancestor the referential style to inherit from
+ */
+ public TtmlStyle chain(TtmlStyle ancestor) {
+ return inherit(ancestor, true);
+ }
+
+ private TtmlStyle inherit(TtmlStyle ancestor, boolean chaining) {
+ if (ancestor != null) {
+ if (!hasFontColor && ancestor.hasFontColor) {
+ setFontColor(ancestor.fontColor);
+ }
+ if (bold == UNSPECIFIED) {
+ bold = ancestor.bold;
+ }
+ if (italic == UNSPECIFIED) {
+ italic = ancestor.italic;
+ }
+ if (fontFamily == null) {
+ fontFamily = ancestor.fontFamily;
+ }
+ if (linethrough == UNSPECIFIED) {
+ linethrough = ancestor.linethrough;
+ }
+ if (underline == UNSPECIFIED) {
+ underline = ancestor.underline;
+ }
+ if (textAlign == null) {
+ textAlign = ancestor.textAlign;
+ }
+ if (fontSizeUnit == UNSPECIFIED) {
+ fontSizeUnit = ancestor.fontSizeUnit;
+ fontSize = ancestor.fontSize;
+ }
+ // attributes not inherited as of http://www.w3.org/TR/ttml1/
+ if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) {
+ setBackgroundColor(ancestor.backgroundColor);
+ }
+ }
+ return this;
+ }
+
+ public TtmlStyle setId(String id) {
+ this.id = id;
+ return this;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public Layout.Alignment getTextAlign() {
+ return textAlign;
+ }
+
+ public TtmlStyle setTextAlign(Layout.Alignment textAlign) {
+ this.textAlign = textAlign;
+ return this;
+ }
+
+ public TtmlStyle setFontSize(float fontSize) {
+ this.fontSize = fontSize;
+ return this;
+ }
+
+ public TtmlStyle setFontSizeUnit(int fontSizeUnit) {
+ this.fontSizeUnit = fontSizeUnit;
+ return this;
+ }
+
+ @FontSizeUnit public int getFontSizeUnit() {
+ return fontSizeUnit;
+ }
+
+ public float getFontSize() {
+ return fontSize;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java
new file mode 100644
index 0000000000..52bd389818
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java
@@ -0,0 +1,81 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import androidx.annotation.VisibleForTesting;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * A representation of a TTML subtitle.
+ */
+/* package */ final class TtmlSubtitle implements Subtitle {
+
+ private final TtmlNode root;
+ private final long[] eventTimesUs;
+ private final Map<String, TtmlStyle> globalStyles;
+ private final Map<String, TtmlRegion> regionMap;
+ private final Map<String, String> imageMap;
+
+ public TtmlSubtitle(
+ TtmlNode root,
+ Map<String, TtmlStyle> globalStyles,
+ Map<String, TtmlRegion> regionMap,
+ Map<String, String> imageMap) {
+ this.root = root;
+ this.regionMap = regionMap;
+ this.imageMap = imageMap;
+ this.globalStyles =
+ globalStyles != null ? Collections.unmodifiableMap(globalStyles) : Collections.emptyMap();
+ this.eventTimesUs = root.getEventTimesUs();
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ int index = Util.binarySearchCeil(eventTimesUs, timeUs, false, false);
+ return index < eventTimesUs.length ? index : C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return eventTimesUs.length;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ return eventTimesUs[index];
+ }
+
+ @VisibleForTesting
+ /* package */ TtmlNode getRoot() {
+ return root;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return root.getCues(timeUs, globalStyles, regionMap, imageMap);
+ }
+
+ @VisibleForTesting
+ /* package */ Map<String, TtmlStyle> getGlobalStyles() {
+ return globalStyles;
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java
new file mode 100644
index 0000000000..e6e7a5a8e3
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java
new file mode 100644
index 0000000000..a6b9ab5c63
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java
@@ -0,0 +1,241 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g;
+
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.UnderlineSpan;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.nio.charset.Charset;
+import java.util.List;
+
+/**
+ * A {@link SimpleSubtitleDecoder} for tx3g.
+ * <p>
+ * Currently supports parsing of a single text track with embedded styles.
+ */
+public final class Tx3gDecoder extends SimpleSubtitleDecoder {
+
+ private static final char BOM_UTF16_BE = '\uFEFF';
+ private static final char BOM_UTF16_LE = '\uFFFE';
+
+ private static final int TYPE_STYL = 0x7374796c;
+ private static final int TYPE_TBOX = 0x74626f78;
+ private static final String TX3G_SERIF = "Serif";
+
+ private static final int SIZE_ATOM_HEADER = 8;
+ private static final int SIZE_SHORT = 2;
+ private static final int SIZE_BOM_UTF16 = 2;
+ private static final int SIZE_STYLE_RECORD = 12;
+
+ private static final int FONT_FACE_BOLD = 0x0001;
+ private static final int FONT_FACE_ITALIC = 0x0002;
+ private static final int FONT_FACE_UNDERLINE = 0x0004;
+
+ private static final int SPAN_PRIORITY_LOW = 0xFF << Spanned.SPAN_PRIORITY_SHIFT;
+ private static final int SPAN_PRIORITY_HIGH = 0;
+
+ private static final int DEFAULT_FONT_FACE = 0;
+ private static final int DEFAULT_COLOR = Color.WHITE;
+ private static final String DEFAULT_FONT_FAMILY = C.SANS_SERIF_NAME;
+ private static final float DEFAULT_VERTICAL_PLACEMENT = 0.85f;
+
+ private final ParsableByteArray parsableByteArray;
+
+ private boolean customVerticalPlacement;
+ private int defaultFontFace;
+ private int defaultColorRgba;
+ private String defaultFontFamily;
+ private float defaultVerticalPlacement;
+ private int calculatedVideoTrackHeight;
+
+ /**
+ * Sets up a new {@link Tx3gDecoder} with default values.
+ *
+ * @param initializationData Sample description atom ('stsd') data with default subtitle styles.
+ */
+ public Tx3gDecoder(List<byte[]> initializationData) {
+ super("Tx3gDecoder");
+ parsableByteArray = new ParsableByteArray();
+
+ if (initializationData != null && initializationData.size() == 1
+ && (initializationData.get(0).length == 48 || initializationData.get(0).length == 53)) {
+ byte[] initializationBytes = initializationData.get(0);
+ defaultFontFace = initializationBytes[24];
+ defaultColorRgba = ((initializationBytes[26] & 0xFF) << 24)
+ | ((initializationBytes[27] & 0xFF) << 16)
+ | ((initializationBytes[28] & 0xFF) << 8)
+ | (initializationBytes[29] & 0xFF);
+ String fontFamily =
+ Util.fromUtf8Bytes(initializationBytes, 43, initializationBytes.length - 43);
+ defaultFontFamily = TX3G_SERIF.equals(fontFamily) ? C.SERIF_NAME : C.SANS_SERIF_NAME;
+ //font size (initializationBytes[25]) is 5% of video height
+ calculatedVideoTrackHeight = 20 * initializationBytes[25];
+ customVerticalPlacement = (initializationBytes[0] & 0x20) != 0;
+ if (customVerticalPlacement) {
+ int requestedVerticalPlacement = ((initializationBytes[10] & 0xFF) << 8)
+ | (initializationBytes[11] & 0xFF);
+ defaultVerticalPlacement = (float) requestedVerticalPlacement / calculatedVideoTrackHeight;
+ defaultVerticalPlacement = Util.constrainValue(defaultVerticalPlacement, 0.0f, 0.95f);
+ } else {
+ defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT;
+ }
+ } else {
+ defaultFontFace = DEFAULT_FONT_FACE;
+ defaultColorRgba = DEFAULT_COLOR;
+ defaultFontFamily = DEFAULT_FONT_FAMILY;
+ customVerticalPlacement = false;
+ defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT;
+ }
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset)
+ throws SubtitleDecoderException {
+ parsableByteArray.reset(bytes, length);
+ String cueTextString = readSubtitleText(parsableByteArray);
+ if (cueTextString.isEmpty()) {
+ return Tx3gSubtitle.EMPTY;
+ }
+ // Attach default styles.
+ SpannableStringBuilder cueText = new SpannableStringBuilder(cueTextString);
+ attachFontFace(cueText, defaultFontFace, DEFAULT_FONT_FACE, 0, cueText.length(),
+ SPAN_PRIORITY_LOW);
+ attachColor(cueText, defaultColorRgba, DEFAULT_COLOR, 0, cueText.length(),
+ SPAN_PRIORITY_LOW);
+ attachFontFamily(cueText, defaultFontFamily, DEFAULT_FONT_FAMILY, 0, cueText.length(),
+ SPAN_PRIORITY_LOW);
+ float verticalPlacement = defaultVerticalPlacement;
+ // Find and attach additional styles.
+ while (parsableByteArray.bytesLeft() >= SIZE_ATOM_HEADER) {
+ int position = parsableByteArray.getPosition();
+ int atomSize = parsableByteArray.readInt();
+ int atomType = parsableByteArray.readInt();
+ if (atomType == TYPE_STYL) {
+ assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT);
+ int styleRecordCount = parsableByteArray.readUnsignedShort();
+ for (int i = 0; i < styleRecordCount; i++) {
+ applyStyleRecord(parsableByteArray, cueText);
+ }
+ } else if (atomType == TYPE_TBOX && customVerticalPlacement) {
+ assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT);
+ int requestedVerticalPlacement = parsableByteArray.readUnsignedShort();
+ verticalPlacement = (float) requestedVerticalPlacement / calculatedVideoTrackHeight;
+ verticalPlacement = Util.constrainValue(verticalPlacement, 0.0f, 0.95f);
+ }
+ parsableByteArray.setPosition(position + atomSize);
+ }
+ return new Tx3gSubtitle(
+ new Cue(
+ cueText,
+ /* textAlignment= */ null,
+ verticalPlacement,
+ Cue.LINE_TYPE_FRACTION,
+ Cue.ANCHOR_TYPE_START,
+ Cue.DIMEN_UNSET,
+ Cue.TYPE_UNSET,
+ Cue.DIMEN_UNSET));
+ }
+
+ private static String readSubtitleText(ParsableByteArray parsableByteArray)
+ throws SubtitleDecoderException {
+ assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT);
+ int textLength = parsableByteArray.readUnsignedShort();
+ if (textLength == 0) {
+ return "";
+ }
+ if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) {
+ char firstChar = parsableByteArray.peekChar();
+ if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) {
+ return parsableByteArray.readString(textLength, Charset.forName(C.UTF16_NAME));
+ }
+ }
+ return parsableByteArray.readString(textLength, Charset.forName(C.UTF8_NAME));
+ }
+
+ private void applyStyleRecord(ParsableByteArray parsableByteArray,
+ SpannableStringBuilder cueText) throws SubtitleDecoderException {
+ assertTrue(parsableByteArray.bytesLeft() >= SIZE_STYLE_RECORD);
+ int start = parsableByteArray.readUnsignedShort();
+ int end = parsableByteArray.readUnsignedShort();
+ parsableByteArray.skipBytes(2); // font identifier
+ int fontFace = parsableByteArray.readUnsignedByte();
+ parsableByteArray.skipBytes(1); // font size
+ int colorRgba = parsableByteArray.readInt();
+ attachFontFace(cueText, fontFace, defaultFontFace, start, end, SPAN_PRIORITY_HIGH);
+ attachColor(cueText, colorRgba, defaultColorRgba, start, end, SPAN_PRIORITY_HIGH);
+ }
+
+ private static void attachFontFace(SpannableStringBuilder cueText, int fontFace,
+ int defaultFontFace, int start, int end, int spanPriority) {
+ if (fontFace != defaultFontFace) {
+ final int flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority;
+ boolean isBold = (fontFace & FONT_FACE_BOLD) != 0;
+ boolean isItalic = (fontFace & FONT_FACE_ITALIC) != 0;
+ if (isBold) {
+ if (isItalic) {
+ cueText.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), start, end, flags);
+ } else {
+ cueText.setSpan(new StyleSpan(Typeface.BOLD), start, end, flags);
+ }
+ } else if (isItalic) {
+ cueText.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flags);
+ }
+ boolean isUnderlined = (fontFace & FONT_FACE_UNDERLINE) != 0;
+ if (isUnderlined) {
+ cueText.setSpan(new UnderlineSpan(), start, end, flags);
+ }
+ if (!isUnderlined && !isBold && !isItalic) {
+ cueText.setSpan(new StyleSpan(Typeface.NORMAL), start, end, flags);
+ }
+ }
+ }
+
+ private static void attachColor(SpannableStringBuilder cueText, int colorRgba,
+ int defaultColorRgba, int start, int end, int spanPriority) {
+ if (colorRgba != defaultColorRgba) {
+ int colorArgb = ((colorRgba & 0xFF) << 24) | (colorRgba >>> 8);
+ cueText.setSpan(new ForegroundColorSpan(colorArgb), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority);
+ }
+ }
+
+ @SuppressWarnings("ReferenceEquality")
+ private static void attachFontFamily(SpannableStringBuilder cueText, String fontFamily,
+ String defaultFontFamily, int start, int end, int spanPriority) {
+ if (fontFamily != defaultFontFamily) {
+ cueText.setSpan(new TypefaceSpan(fontFamily), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority);
+ }
+ }
+
+ private static void assertTrue(boolean checkValue) throws SubtitleDecoderException {
+ if (!checkValue) {
+ throw new SubtitleDecoderException("Unexpected subtitle format.");
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java
new file mode 100644
index 0000000000..93bc6034d1
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java
@@ -0,0 +1,63 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A representation of a tx3g subtitle.
+ */
+/* package */ final class Tx3gSubtitle implements Subtitle {
+
+ public static final Tx3gSubtitle EMPTY = new Tx3gSubtitle();
+
+ private final List<Cue> cues;
+
+ public Tx3gSubtitle(Cue cue) {
+ this.cues = Collections.singletonList(cue);
+ }
+
+ private Tx3gSubtitle() {
+ this.cues = Collections.emptyList();
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return timeUs < 0 ? 0 : C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ Assertions.checkArgument(index == 0);
+ return 0;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return timeUs >= 0 ? cues : Collections.emptyList();
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java
new file mode 100644
index 0000000000..7bac8c12b6
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java
new file mode 100644
index 0000000000..3337cc3481
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java
@@ -0,0 +1,347 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import android.text.TextUtils;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ColorParser;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Provides a CSS parser for STYLE blocks in Webvtt files. Supports only a subset of the CSS
+ * features.
+ */
+/* package */ final class CssParser {
+
+ private static final String PROPERTY_BGCOLOR = "background-color";
+ private static final String PROPERTY_FONT_FAMILY = "font-family";
+ private static final String PROPERTY_FONT_WEIGHT = "font-weight";
+ private static final String PROPERTY_TEXT_DECORATION = "text-decoration";
+ private static final String VALUE_BOLD = "bold";
+ private static final String VALUE_UNDERLINE = "underline";
+ private static final String RULE_START = "{";
+ private static final String RULE_END = "}";
+ private static final String PROPERTY_FONT_STYLE = "font-style";
+ private static final String VALUE_ITALIC = "italic";
+
+ private static final Pattern VOICE_NAME_PATTERN = Pattern.compile("\\[voice=\"([^\"]*)\"\\]");
+
+ // Temporary utility data structures.
+ private final ParsableByteArray styleInput;
+ private final StringBuilder stringBuilder;
+
+ public CssParser() {
+ styleInput = new ParsableByteArray();
+ stringBuilder = new StringBuilder();
+ }
+
+ /**
+ * Takes a CSS style block and consumes up to the first empty line. Attempts to parse the contents
+ * of the style block and returns a list of {@link WebvttCssStyle} instances if successful. If
+ * parsing fails, it returns a list including only the styles which have been successfully parsed
+ * up to the style rule which was malformed.
+ *
+ * @param input The input from which the style block should be read.
+ * @return A list of {@link WebvttCssStyle}s that represents the parsed block, or a list
+ * containing the styles up to the parsing failure.
+ */
+ public List<WebvttCssStyle> parseBlock(ParsableByteArray input) {
+ stringBuilder.setLength(0);
+ int initialInputPosition = input.getPosition();
+ skipStyleBlock(input);
+ styleInput.reset(input.data, input.getPosition());
+ styleInput.setPosition(initialInputPosition);
+
+ List<WebvttCssStyle> styles = new ArrayList<>();
+ String selector;
+ while ((selector = parseSelector(styleInput, stringBuilder)) != null) {
+ if (!RULE_START.equals(parseNextToken(styleInput, stringBuilder))) {
+ return styles;
+ }
+ WebvttCssStyle style = new WebvttCssStyle();
+ applySelectorToStyle(style, selector);
+ String token = null;
+ boolean blockEndFound = false;
+ while (!blockEndFound) {
+ int position = styleInput.getPosition();
+ token = parseNextToken(styleInput, stringBuilder);
+ blockEndFound = token == null || RULE_END.equals(token);
+ if (!blockEndFound) {
+ styleInput.setPosition(position);
+ parseStyleDeclaration(styleInput, style, stringBuilder);
+ }
+ }
+ // Check that the style rule ended correctly.
+ if (RULE_END.equals(token)) {
+ styles.add(style);
+ }
+ }
+ return styles;
+ }
+
+ /**
+ * Returns a string containing the selector. The input is expected to have the form {@code
+ * ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional.
+ *
+ * @param input From which the selector is obtained.
+ * @return A string containing the target, empty string if the selector is universal (targets all
+ * cues) or null if an error was encountered.
+ */
+ @Nullable
+ private static String parseSelector(ParsableByteArray input, StringBuilder stringBuilder) {
+ skipWhitespaceAndComments(input);
+ if (input.bytesLeft() < 5) {
+ return null;
+ }
+ String cueSelector = input.readString(5);
+ if (!"::cue".equals(cueSelector)) {
+ return null;
+ }
+ int position = input.getPosition();
+ String token = parseNextToken(input, stringBuilder);
+ if (token == null) {
+ return null;
+ }
+ if (RULE_START.equals(token)) {
+ input.setPosition(position);
+ return "";
+ }
+ String target = null;
+ if ("(".equals(token)) {
+ target = readCueTarget(input);
+ }
+ token = parseNextToken(input, stringBuilder);
+ if (!")".equals(token)) {
+ return null;
+ }
+ return target;
+ }
+
+ /**
+ * Reads the contents of ::cue() and returns it as a string.
+ */
+ private static String readCueTarget(ParsableByteArray input) {
+ int position = input.getPosition();
+ int limit = input.limit();
+ boolean cueTargetEndFound = false;
+ while (position < limit && !cueTargetEndFound) {
+ char c = (char) input.data[position++];
+ cueTargetEndFound = c == ')';
+ }
+ return input.readString(--position - input.getPosition()).trim();
+ // --offset to return ')' to the input.
+ }
+
+ private static void parseStyleDeclaration(ParsableByteArray input, WebvttCssStyle style,
+ StringBuilder stringBuilder) {
+ skipWhitespaceAndComments(input);
+ String property = parseIdentifier(input, stringBuilder);
+ if ("".equals(property)) {
+ return;
+ }
+ if (!":".equals(parseNextToken(input, stringBuilder))) {
+ return;
+ }
+ skipWhitespaceAndComments(input);
+ String value = parsePropertyValue(input, stringBuilder);
+ if (value == null || "".equals(value)) {
+ return;
+ }
+ int position = input.getPosition();
+ String token = parseNextToken(input, stringBuilder);
+ if (";".equals(token)) {
+ // The style declaration is well formed.
+ } else if (RULE_END.equals(token)) {
+ // The style declaration is well formed and we can go on, but the closing bracket had to be
+ // fed back.
+ input.setPosition(position);
+ } else {
+ // The style declaration is not well formed.
+ return;
+ }
+ // At this point we have a presumably valid declaration, we need to parse it and fill the style.
+ if ("color".equals(property)) {
+ style.setFontColor(ColorParser.parseCssColor(value));
+ } else if (PROPERTY_BGCOLOR.equals(property)) {
+ style.setBackgroundColor(ColorParser.parseCssColor(value));
+ } else if (PROPERTY_TEXT_DECORATION.equals(property)) {
+ if (VALUE_UNDERLINE.equals(value)) {
+ style.setUnderline(true);
+ }
+ } else if (PROPERTY_FONT_FAMILY.equals(property)) {
+ style.setFontFamily(value);
+ } else if (PROPERTY_FONT_WEIGHT.equals(property)) {
+ if (VALUE_BOLD.equals(value)) {
+ style.setBold(true);
+ }
+ } else if (PROPERTY_FONT_STYLE.equals(property)) {
+ if (VALUE_ITALIC.equals(value)) {
+ style.setItalic(true);
+ }
+ }
+ // TODO: Fill remaining supported styles.
+ }
+
+ // Visible for testing.
+ /* package */ static void skipWhitespaceAndComments(ParsableByteArray input) {
+ boolean skipping = true;
+ while (input.bytesLeft() > 0 && skipping) {
+ skipping = maybeSkipWhitespace(input) || maybeSkipComment(input);
+ }
+ }
+
+ // Visible for testing.
+ @Nullable
+ /* package */ static String parseNextToken(ParsableByteArray input, StringBuilder stringBuilder) {
+ skipWhitespaceAndComments(input);
+ if (input.bytesLeft() == 0) {
+ return null;
+ }
+ String identifier = parseIdentifier(input, stringBuilder);
+ if (!"".equals(identifier)) {
+ return identifier;
+ }
+ // We found a delimiter.
+ return "" + (char) input.readUnsignedByte();
+ }
+
+ private static boolean maybeSkipWhitespace(ParsableByteArray input) {
+ switch(peekCharAtPosition(input, input.getPosition())) {
+ case '\t':
+ case '\r':
+ case '\n':
+ case '\f':
+ case ' ':
+ input.skipBytes(1);
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ // Visible for testing.
+ /* package */ static void skipStyleBlock(ParsableByteArray input) {
+ // The style block cannot contain empty lines, so we assume the input ends when a empty line
+ // is found.
+ String line;
+ do {
+ line = input.readLine();
+ } while (!TextUtils.isEmpty(line));
+ }
+
+ private static char peekCharAtPosition(ParsableByteArray input, int position) {
+ return (char) input.data[position];
+ }
+
+ @Nullable
+ private static String parsePropertyValue(ParsableByteArray input, StringBuilder stringBuilder) {
+ StringBuilder expressionBuilder = new StringBuilder();
+ String token;
+ int position;
+ boolean expressionEndFound = false;
+ // TODO: Add support for "Strings in quotes with spaces".
+ while (!expressionEndFound) {
+ position = input.getPosition();
+ token = parseNextToken(input, stringBuilder);
+ if (token == null) {
+ // Syntax error.
+ return null;
+ }
+ if (RULE_END.equals(token) || ";".equals(token)) {
+ input.setPosition(position);
+ expressionEndFound = true;
+ } else {
+ expressionBuilder.append(token);
+ }
+ }
+ return expressionBuilder.toString();
+ }
+
+ private static boolean maybeSkipComment(ParsableByteArray input) {
+ int position = input.getPosition();
+ int limit = input.limit();
+ byte[] data = input.data;
+ if (position + 2 <= limit && data[position++] == '/' && data[position++] == '*') {
+ while (position + 1 < limit) {
+ char skippedChar = (char) data[position++];
+ if (skippedChar == '*') {
+ if (((char) data[position]) == '/') {
+ position++;
+ limit = position;
+ }
+ }
+ }
+ input.skipBytes(limit - input.getPosition());
+ return true;
+ }
+ return false;
+ }
+
+ private static String parseIdentifier(ParsableByteArray input, StringBuilder stringBuilder) {
+ stringBuilder.setLength(0);
+ int position = input.getPosition();
+ int limit = input.limit();
+ boolean identifierEndFound = false;
+ while (position < limit && !identifierEndFound) {
+ char c = (char) input.data[position];
+ if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '#'
+ || c == '-' || c == '.' || c == '_') {
+ position++;
+ stringBuilder.append(c);
+ } else {
+ identifierEndFound = true;
+ }
+ }
+ input.skipBytes(position - input.getPosition());
+ return stringBuilder.toString();
+ }
+
+ /**
+ * Sets the target of a {@link WebvttCssStyle} by splitting a selector of the form
+ * {@code ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional.
+ */
+ private void applySelectorToStyle(WebvttCssStyle style, String selector) {
+ if ("".equals(selector)) {
+ return; // Universal selector.
+ }
+ int voiceStartIndex = selector.indexOf('[');
+ if (voiceStartIndex != -1) {
+ Matcher matcher = VOICE_NAME_PATTERN.matcher(selector.substring(voiceStartIndex));
+ if (matcher.matches()) {
+ style.setTargetVoice(matcher.group(1));
+ }
+ selector = selector.substring(0, voiceStartIndex);
+ }
+ String[] classDivision = Util.split(selector, "\\.");
+ String tagAndIdDivision = classDivision[0];
+ int idPrefixIndex = tagAndIdDivision.indexOf('#');
+ if (idPrefixIndex != -1) {
+ style.setTargetTagName(tagAndIdDivision.substring(0, idPrefixIndex));
+ style.setTargetId(tagAndIdDivision.substring(idPrefixIndex + 1)); // We discard the '#'.
+ } else {
+ style.setTargetTagName(tagAndIdDivision);
+ }
+ if (classDivision.length > 1) {
+ style.setTargetClasses(Util.nullSafeArrayCopyOfRange(classDivision, 1, classDivision.length));
+ }
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java
new file mode 100644
index 0000000000..3df35c789b
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java
@@ -0,0 +1,101 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** A {@link SimpleSubtitleDecoder} for Webvtt embedded in a Mp4 container file. */
+@SuppressWarnings("ConstantField")
+public final class Mp4WebvttDecoder extends SimpleSubtitleDecoder {
+
+ private static final int BOX_HEADER_SIZE = 8;
+
+ @SuppressWarnings("ConstantCaseForConstants")
+ private static final int TYPE_payl = 0x7061796c;
+
+ @SuppressWarnings("ConstantCaseForConstants")
+ private static final int TYPE_sttg = 0x73747467;
+
+ @SuppressWarnings("ConstantCaseForConstants")
+ private static final int TYPE_vttc = 0x76747463;
+
+ private final ParsableByteArray sampleData;
+ private final WebvttCue.Builder builder;
+
+ public Mp4WebvttDecoder() {
+ super("Mp4WebvttDecoder");
+ sampleData = new ParsableByteArray();
+ builder = new WebvttCue.Builder();
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset)
+ throws SubtitleDecoderException {
+ // Webvtt in Mp4 samples have boxes inside of them, so we have to do a traditional box parsing:
+ // first 4 bytes size and then 4 bytes type.
+ sampleData.reset(bytes, length);
+ List<Cue> resultingCueList = new ArrayList<>();
+ while (sampleData.bytesLeft() > 0) {
+ if (sampleData.bytesLeft() < BOX_HEADER_SIZE) {
+ throw new SubtitleDecoderException("Incomplete Mp4Webvtt Top Level box header found.");
+ }
+ int boxSize = sampleData.readInt();
+ int boxType = sampleData.readInt();
+ if (boxType == TYPE_vttc) {
+ resultingCueList.add(parseVttCueBox(sampleData, builder, boxSize - BOX_HEADER_SIZE));
+ } else {
+ // Peers of the VTTCueBox are still not supported and are skipped.
+ sampleData.skipBytes(boxSize - BOX_HEADER_SIZE);
+ }
+ }
+ return new Mp4WebvttSubtitle(resultingCueList);
+ }
+
+ private static Cue parseVttCueBox(ParsableByteArray sampleData, WebvttCue.Builder builder,
+ int remainingCueBoxBytes) throws SubtitleDecoderException {
+ builder.reset();
+ while (remainingCueBoxBytes > 0) {
+ if (remainingCueBoxBytes < BOX_HEADER_SIZE) {
+ throw new SubtitleDecoderException("Incomplete vtt cue box header found.");
+ }
+ int boxSize = sampleData.readInt();
+ int boxType = sampleData.readInt();
+ remainingCueBoxBytes -= BOX_HEADER_SIZE;
+ int payloadLength = boxSize - BOX_HEADER_SIZE;
+ String boxPayload =
+ Util.fromUtf8Bytes(sampleData.data, sampleData.getPosition(), payloadLength);
+ sampleData.skipBytes(payloadLength);
+ remainingCueBoxBytes -= payloadLength;
+ if (boxType == TYPE_sttg) {
+ WebvttCueParser.parseCueSettingsList(boxPayload, builder);
+ } else if (boxType == TYPE_payl) {
+ WebvttCueParser.parseCueText(null, boxPayload.trim(), builder, Collections.emptyList());
+ } else {
+ // Other VTTCueBox children are still not supported and are ignored.
+ }
+ }
+ return builder.build();
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java
new file mode 100644
index 0000000000..545e8b2511
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java
@@ -0,0 +1,56 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Representation of a Webvtt subtitle embedded in a MP4 container file.
+ */
+/* package */ final class Mp4WebvttSubtitle implements Subtitle {
+
+ private final List<Cue> cues;
+
+ public Mp4WebvttSubtitle(List<Cue> cueList) {
+ cues = Collections.unmodifiableList(cueList);
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ return timeUs < 0 ? 0 : C.INDEX_UNSET;
+ }
+
+ @Override
+ public int getEventTimeCount() {
+ return 1;
+ }
+
+ @Override
+ public long getEventTime(int index) {
+ Assertions.checkArgument(index == 0);
+ return 0;
+ }
+
+ @Override
+ public List<Cue> getCues(long timeUs) {
+ return timeUs >= 0 ? cues : Collections.emptyList();
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java
new file mode 100644
index 0000000000..da37cfbdf3
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java
@@ -0,0 +1,329 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import android.graphics.Typeface;
+import android.text.Layout;
+import android.text.TextUtils;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.checkerframework.checker.nullness.qual.EnsuresNonNull;
+
+/**
+ * Style object of a Css style block in a Webvtt file.
+ *
+ * @see <a href="https://w3c.github.io/webvtt/#applying-css-properties">W3C specification - Apply
+ * CSS properties</a>
+ */
+public final class WebvttCssStyle {
+
+ public static final int UNSPECIFIED = -1;
+
+ /**
+ * Style flag enum. Possible flag values are {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link
+ * #STYLE_BOLD}, {@link #STYLE_ITALIC} and {@link #STYLE_BOLD_ITALIC}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef(
+ flag = true,
+ value = {UNSPECIFIED, STYLE_NORMAL, STYLE_BOLD, STYLE_ITALIC, STYLE_BOLD_ITALIC})
+ public @interface StyleFlags {}
+
+ public static final int STYLE_NORMAL = Typeface.NORMAL;
+ public static final int STYLE_BOLD = Typeface.BOLD;
+ public static final int STYLE_ITALIC = Typeface.ITALIC;
+ public static final int STYLE_BOLD_ITALIC = Typeface.BOLD_ITALIC;
+
+ /**
+ * Font size unit enum. One of {@link #UNSPECIFIED}, {@link #FONT_SIZE_UNIT_PIXEL}, {@link
+ * #FONT_SIZE_UNIT_EM} or {@link #FONT_SIZE_UNIT_PERCENT}.
+ */
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({UNSPECIFIED, FONT_SIZE_UNIT_PIXEL, FONT_SIZE_UNIT_EM, FONT_SIZE_UNIT_PERCENT})
+ public @interface FontSizeUnit {}
+
+ public static final int FONT_SIZE_UNIT_PIXEL = 1;
+ public static final int FONT_SIZE_UNIT_EM = 2;
+ public static final int FONT_SIZE_UNIT_PERCENT = 3;
+
+ @Documented
+ @Retention(RetentionPolicy.SOURCE)
+ @IntDef({UNSPECIFIED, OFF, ON})
+ private @interface OptionalBoolean {}
+
+ private static final int OFF = 0;
+ private static final int ON = 1;
+
+ // Selector properties.
+ private String targetId;
+ private String targetTag;
+ private List<String> targetClasses;
+ private String targetVoice;
+
+ // Style properties.
+ @Nullable private String fontFamily;
+ private int fontColor;
+ private boolean hasFontColor;
+ private int backgroundColor;
+ private boolean hasBackgroundColor;
+ @OptionalBoolean private int linethrough;
+ @OptionalBoolean private int underline;
+ @OptionalBoolean private int bold;
+ @OptionalBoolean private int italic;
+ @FontSizeUnit private int fontSizeUnit;
+ private float fontSize;
+ @Nullable private Layout.Alignment textAlign;
+
+ // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed
+ // because reset() only assigns fields, it doesn't read any.
+ @SuppressWarnings("nullness:method.invocation.invalid")
+ public WebvttCssStyle() {
+ reset();
+ }
+
+ @EnsuresNonNull({"targetId", "targetTag", "targetClasses", "targetVoice"})
+ public void reset() {
+ targetId = "";
+ targetTag = "";
+ targetClasses = Collections.emptyList();
+ targetVoice = "";
+ fontFamily = null;
+ hasFontColor = false;
+ hasBackgroundColor = false;
+ linethrough = UNSPECIFIED;
+ underline = UNSPECIFIED;
+ bold = UNSPECIFIED;
+ italic = UNSPECIFIED;
+ fontSizeUnit = UNSPECIFIED;
+ textAlign = null;
+ }
+
+ public void setTargetId(String targetId) {
+ this.targetId = targetId;
+ }
+
+ public void setTargetTagName(String targetTag) {
+ this.targetTag = targetTag;
+ }
+
+ public void setTargetClasses(String[] targetClasses) {
+ this.targetClasses = Arrays.asList(targetClasses);
+ }
+
+ public void setTargetVoice(String targetVoice) {
+ this.targetVoice = targetVoice;
+ }
+
+ /**
+ * Returns a value in a score system compliant with the CSS Specificity rules.
+ *
+ * @see <a href="https://www.w3.org/TR/CSS2/cascade.html">CSS Cascading</a>
+ * <p>The score works as follows:
+ * <ul>
+ * <li>Id match adds 0x40000000 to the score.
+ * <li>Each class and voice match adds 4 to the score.
+ * <li>Tag matching adds 2 to the score.
+ * <li>Universal selector matching scores 1.
+ * </ul>
+ *
+ * @param id The id of the cue if present, {@code null} otherwise.
+ * @param tag Name of the tag, {@code null} if it refers to the entire cue.
+ * @param classes An array containing the classes the tag belongs to. Must not be null.
+ * @param voice Annotated voice if present, {@code null} otherwise.
+ * @return The score of the match, zero if there is no match.
+ */
+ public int getSpecificityScore(
+ @Nullable String id, @Nullable String tag, String[] classes, @Nullable String voice) {
+ if (targetId.isEmpty() && targetTag.isEmpty() && targetClasses.isEmpty()
+ && targetVoice.isEmpty()) {
+ // The selector is universal. It matches with the minimum score if and only if the given
+ // element is a whole cue.
+ return TextUtils.isEmpty(tag) ? 1 : 0;
+ }
+ int score = 0;
+ score = updateScoreForMatch(score, targetId, id, 0x40000000);
+ score = updateScoreForMatch(score, targetTag, tag, 2);
+ score = updateScoreForMatch(score, targetVoice, voice, 4);
+ if (score == -1 || !Arrays.asList(classes).containsAll(targetClasses)) {
+ return 0;
+ } else {
+ score += targetClasses.size() * 4;
+ }
+ return score;
+ }
+
+ /**
+ * Returns the style or {@link #UNSPECIFIED} when no style information is given.
+ *
+ * @return {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link #STYLE_BOLD}, {@link #STYLE_BOLD}
+ * or {@link #STYLE_BOLD_ITALIC}.
+ */
+ @StyleFlags public int getStyle() {
+ if (bold == UNSPECIFIED && italic == UNSPECIFIED) {
+ return UNSPECIFIED;
+ }
+ return (bold == ON ? STYLE_BOLD : STYLE_NORMAL)
+ | (italic == ON ? STYLE_ITALIC : STYLE_NORMAL);
+ }
+
+ public boolean isLinethrough() {
+ return linethrough == ON;
+ }
+
+ public WebvttCssStyle setLinethrough(boolean linethrough) {
+ this.linethrough = linethrough ? ON : OFF;
+ return this;
+ }
+
+ public boolean isUnderline() {
+ return underline == ON;
+ }
+
+ public WebvttCssStyle setUnderline(boolean underline) {
+ this.underline = underline ? ON : OFF;
+ return this;
+ }
+ public WebvttCssStyle setBold(boolean bold) {
+ this.bold = bold ? ON : OFF;
+ return this;
+ }
+
+ public WebvttCssStyle setItalic(boolean italic) {
+ this.italic = italic ? ON : OFF;
+ return this;
+ }
+
+ @Nullable
+ public String getFontFamily() {
+ return fontFamily;
+ }
+
+ public WebvttCssStyle setFontFamily(@Nullable String fontFamily) {
+ this.fontFamily = Util.toLowerInvariant(fontFamily);
+ return this;
+ }
+
+ public int getFontColor() {
+ if (!hasFontColor) {
+ throw new IllegalStateException("Font color not defined");
+ }
+ return fontColor;
+ }
+
+ public WebvttCssStyle setFontColor(int color) {
+ this.fontColor = color;
+ hasFontColor = true;
+ return this;
+ }
+
+ public boolean hasFontColor() {
+ return hasFontColor;
+ }
+
+ public int getBackgroundColor() {
+ if (!hasBackgroundColor) {
+ throw new IllegalStateException("Background color not defined.");
+ }
+ return backgroundColor;
+ }
+
+ public WebvttCssStyle setBackgroundColor(int backgroundColor) {
+ this.backgroundColor = backgroundColor;
+ hasBackgroundColor = true;
+ return this;
+ }
+
+ public boolean hasBackgroundColor() {
+ return hasBackgroundColor;
+ }
+
+ @Nullable
+ public Layout.Alignment getTextAlign() {
+ return textAlign;
+ }
+
+ public WebvttCssStyle setTextAlign(@Nullable Layout.Alignment textAlign) {
+ this.textAlign = textAlign;
+ return this;
+ }
+
+ public WebvttCssStyle setFontSize(float fontSize) {
+ this.fontSize = fontSize;
+ return this;
+ }
+
+ public WebvttCssStyle setFontSizeUnit(short unit) {
+ this.fontSizeUnit = unit;
+ return this;
+ }
+
+ @FontSizeUnit public int getFontSizeUnit() {
+ return fontSizeUnit;
+ }
+
+ public float getFontSize() {
+ return fontSize;
+ }
+
+ public void cascadeFrom(WebvttCssStyle style) {
+ if (style.hasFontColor) {
+ setFontColor(style.fontColor);
+ }
+ if (style.bold != UNSPECIFIED) {
+ bold = style.bold;
+ }
+ if (style.italic != UNSPECIFIED) {
+ italic = style.italic;
+ }
+ if (style.fontFamily != null) {
+ fontFamily = style.fontFamily;
+ }
+ if (linethrough == UNSPECIFIED) {
+ linethrough = style.linethrough;
+ }
+ if (underline == UNSPECIFIED) {
+ underline = style.underline;
+ }
+ if (textAlign == null) {
+ textAlign = style.textAlign;
+ }
+ if (fontSizeUnit == UNSPECIFIED) {
+ fontSizeUnit = style.fontSizeUnit;
+ fontSize = style.fontSize;
+ }
+ if (style.hasBackgroundColor) {
+ setBackgroundColor(style.backgroundColor);
+ }
+ }
+
+ private static int updateScoreForMatch(
+ int currentScore, String target, @Nullable String actual, int score) {
+ if (target.isEmpty() || currentScore == -1) {
+ return currentScore;
+ }
+ return target.equals(actual) ? currentScore + score : -1;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java
new file mode 100644
index 0000000000..af701d8f54
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java
@@ -0,0 +1,319 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
+import android.text.Layout.Alignment;
+import androidx.annotation.IntDef;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+
+/** A representation of a WebVTT cue. */
+public final class WebvttCue extends Cue {
+
+ private static final float DEFAULT_POSITION = 0.5f;
+
+ public final long startTime;
+ public final long endTime;
+
+ private WebvttCue(
+ long startTime,
+ long endTime,
+ CharSequence text,
+ @Nullable Alignment textAlignment,
+ float line,
+ @Cue.LineType int lineType,
+ @Cue.AnchorType int lineAnchor,
+ float position,
+ @Cue.AnchorType int positionAnchor,
+ float width) {
+ super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, width);
+ 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 Whether this cue should be placed in the default position.
+ */
+ public boolean isNormalCue() {
+ return (line == DIMEN_UNSET && position == DEFAULT_POSITION);
+ }
+
+ /** Builder for WebVTT cues. */
+ @SuppressWarnings("hiding")
+ public static class Builder {
+
+ /**
+ * Valid values for {@link #setTextAlignment(int)}.
+ *
+ * <p>We use a custom list (and not {@link Alignment} directly) in order to include both {@code
+ * START}/{@code LEFT} and {@code END}/{@code RIGHT}. The distinction is important for {@link
+ * #derivePosition(int)}.
+ *
+ * <p>These correspond to the valid values for the 'align' cue setting in the <a
+ * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment">WebVTT spec</a>.
+ */
+ @Documented
+ @Retention(SOURCE)
+ @IntDef({
+ TEXT_ALIGNMENT_START,
+ TEXT_ALIGNMENT_CENTER,
+ TEXT_ALIGNMENT_END,
+ TEXT_ALIGNMENT_LEFT,
+ TEXT_ALIGNMENT_RIGHT
+ })
+ public @interface TextAlignment {}
+ /**
+ * See WebVTT's <a
+ * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-start-alignment">align:start</a>.
+ */
+ public static final int TEXT_ALIGNMENT_START = 1;
+
+ /**
+ * See WebVTT's <a
+ * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-center-alignment">align:center</a>.
+ */
+ public static final int TEXT_ALIGNMENT_CENTER = 2;
+
+ /**
+ * See WebVTT's <a href="https://www.w3.org/TR/webvtt1/#webvtt-cue-end-alignment">align:end</a>.
+ */
+ public static final int TEXT_ALIGNMENT_END = 3;
+
+ /**
+ * See WebVTT's <a href="https://www.w3.org/TR/webvtt1/#webvtt-cue-left-alignment">align:left</a>.
+ */
+ public static final int TEXT_ALIGNMENT_LEFT = 4;
+
+ /**
+ * See WebVTT's <a
+ * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-right-alignment">align:right</a>.
+ */
+ public static final int TEXT_ALIGNMENT_RIGHT = 5;
+
+ private static final String TAG = "WebvttCueBuilder";
+
+ private long startTime;
+ private long endTime;
+ @Nullable private CharSequence text;
+ @TextAlignment private int textAlignment;
+ private float line;
+ // Equivalent to WebVTT's snap-to-lines flag:
+ // https://www.w3.org/TR/webvtt1/#webvtt-cue-snap-to-lines-flag
+ @LineType private int lineType;
+ @AnchorType private int lineAnchor;
+ private float position;
+ @AnchorType private int positionAnchor;
+ private float width;
+
+ // Initialization methods
+
+ // Calling reset() is forbidden because `this` isn't initialized. This can be safely
+ // suppressed because reset() only assigns fields, it doesn't read any.
+ @SuppressWarnings("nullness:method.invocation.invalid")
+ public Builder() {
+ reset();
+ }
+
+ public void reset() {
+ startTime = 0;
+ endTime = 0;
+ text = null;
+ // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment
+ textAlignment = TEXT_ALIGNMENT_CENTER;
+ line = Cue.DIMEN_UNSET;
+ // Defaults to NUMBER (true): https://www.w3.org/TR/webvtt1/#webvtt-cue-snap-to-lines-flag
+ lineType = Cue.LINE_TYPE_NUMBER;
+ // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-line-alignment
+ lineAnchor = Cue.ANCHOR_TYPE_START;
+ position = Cue.DIMEN_UNSET;
+ positionAnchor = Cue.TYPE_UNSET;
+ // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-size
+ width = 1.0f;
+ }
+
+ // Construction methods.
+
+ public WebvttCue build() {
+ line = computeLine(line, lineType);
+
+ if (position == Cue.DIMEN_UNSET) {
+ position = derivePosition(textAlignment);
+ }
+
+ if (positionAnchor == Cue.TYPE_UNSET) {
+ positionAnchor = derivePositionAnchor(textAlignment);
+ }
+
+ width = Math.min(width, deriveMaxSize(positionAnchor, position));
+
+ return new WebvttCue(
+ startTime,
+ endTime,
+ Assertions.checkNotNull(text),
+ convertTextAlignment(textAlignment),
+ line,
+ lineType,
+ lineAnchor,
+ position,
+ positionAnchor,
+ width);
+ }
+
+ public Builder setStartTime(long time) {
+ startTime = time;
+ return this;
+ }
+
+ public Builder setEndTime(long time) {
+ endTime = time;
+ return this;
+ }
+
+ public Builder setText(CharSequence text) {
+ this.text = text;
+ return this;
+ }
+
+ public Builder setTextAlignment(@TextAlignment int textAlignment) {
+ this.textAlignment = textAlignment;
+ return this;
+ }
+
+ public Builder setLine(float line) {
+ this.line = line;
+ return this;
+ }
+
+ public Builder setLineType(@LineType int lineType) {
+ this.lineType = lineType;
+ return this;
+ }
+
+ public Builder setLineAnchor(@AnchorType int lineAnchor) {
+ this.lineAnchor = lineAnchor;
+ return this;
+ }
+
+ public Builder setPosition(float position) {
+ this.position = position;
+ return this;
+ }
+
+ public Builder setPositionAnchor(@AnchorType int positionAnchor) {
+ this.positionAnchor = positionAnchor;
+ return this;
+ }
+
+ public Builder setWidth(float width) {
+ this.width = width;
+ return this;
+ }
+
+ // https://www.w3.org/TR/webvtt1/#webvtt-cue-line
+ private static float computeLine(float line, @LineType int lineType) {
+ if (line != Cue.DIMEN_UNSET
+ && lineType == Cue.LINE_TYPE_FRACTION
+ && (line < 0.0f || line > 1.0f)) {
+ return 1.0f; // Step 1
+ } else if (line != Cue.DIMEN_UNSET) {
+ // Step 2: Do nothing, line is already correct.
+ return line;
+ } else if (lineType == Cue.LINE_TYPE_FRACTION) {
+ return 1.0f; // Step 3
+ } else {
+ // Steps 4 - 10 (stacking multiple simultaneous cues) are handled by WebvttSubtitle#getCues
+ // and WebvttCue#isNormalCue.
+ return DIMEN_UNSET;
+ }
+ }
+
+ // https://www.w3.org/TR/webvtt1/#webvtt-cue-position
+ private static float derivePosition(@TextAlignment int textAlignment) {
+ switch (textAlignment) {
+ case TEXT_ALIGNMENT_LEFT:
+ return 0.0f;
+ case TEXT_ALIGNMENT_RIGHT:
+ return 1.0f;
+ case TEXT_ALIGNMENT_START:
+ case TEXT_ALIGNMENT_CENTER:
+ case TEXT_ALIGNMENT_END:
+ default:
+ return DEFAULT_POSITION;
+ }
+ }
+
+ // https://www.w3.org/TR/webvtt1/#webvtt-cue-position-alignment
+ @AnchorType
+ private static int derivePositionAnchor(@TextAlignment int textAlignment) {
+ switch (textAlignment) {
+ case TEXT_ALIGNMENT_LEFT:
+ case TEXT_ALIGNMENT_START:
+ return Cue.ANCHOR_TYPE_START;
+ case TEXT_ALIGNMENT_RIGHT:
+ case TEXT_ALIGNMENT_END:
+ return Cue.ANCHOR_TYPE_END;
+ case TEXT_ALIGNMENT_CENTER:
+ default:
+ return Cue.ANCHOR_TYPE_MIDDLE;
+ }
+ }
+
+ @Nullable
+ private static Alignment convertTextAlignment(@TextAlignment int textAlignment) {
+ switch (textAlignment) {
+ case TEXT_ALIGNMENT_START:
+ case TEXT_ALIGNMENT_LEFT:
+ return Alignment.ALIGN_NORMAL;
+ case TEXT_ALIGNMENT_CENTER:
+ return Alignment.ALIGN_CENTER;
+ case TEXT_ALIGNMENT_END:
+ case TEXT_ALIGNMENT_RIGHT:
+ return Alignment.ALIGN_OPPOSITE;
+ default:
+ Log.w(TAG, "Unknown textAlignment: " + textAlignment);
+ return null;
+ }
+ }
+
+ // Step 2 here: https://www.w3.org/TR/webvtt1/#processing-cue-settings
+ private static float deriveMaxSize(@AnchorType int positionAnchor, float position) {
+ switch (positionAnchor) {
+ case Cue.ANCHOR_TYPE_START:
+ return 1.0f - position;
+ case Cue.ANCHOR_TYPE_END:
+ return position;
+ case Cue.ANCHOR_TYPE_MIDDLE:
+ if (position <= 0.5f) {
+ return position * 2;
+ } else {
+ return (1.0f - position) * 2;
+ }
+ case Cue.TYPE_UNSET:
+ default:
+ throw new IllegalStateException(String.valueOf(positionAnchor));
+ }
+ }
+ }
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java
new file mode 100644
index 0000000000..b370e67792
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java
@@ -0,0 +1,550 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import android.graphics.Typeface;
+import android.text.Layout;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextUtils;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.AlignmentSpan;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.UnderlineSpan;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** Parser for WebVTT cues. (https://w3c.github.io/webvtt/#cues) */
+public final class WebvttCueParser {
+
+ public static final Pattern CUE_HEADER_PATTERN = Pattern
+ .compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$");
+
+ private static final Pattern CUE_SETTING_PATTERN = Pattern.compile("(\\S+?):(\\S+)");
+
+ private static final char CHAR_LESS_THAN = '<';
+ private static final char CHAR_GREATER_THAN = '>';
+ private static final char CHAR_SLASH = '/';
+ private static final char CHAR_AMPERSAND = '&';
+ private static final char CHAR_SEMI_COLON = ';';
+ private static final char CHAR_SPACE = ' ';
+
+ private static final String ENTITY_LESS_THAN = "lt";
+ private static final String ENTITY_GREATER_THAN = "gt";
+ private static final String ENTITY_AMPERSAND = "amp";
+ private static final String ENTITY_NON_BREAK_SPACE = "nbsp";
+
+ private static final String TAG_BOLD = "b";
+ private static final String TAG_ITALIC = "i";
+ private static final String TAG_UNDERLINE = "u";
+ private static final String TAG_CLASS = "c";
+ private static final String TAG_VOICE = "v";
+ private static final String TAG_LANG = "lang";
+
+ private static final int STYLE_BOLD = Typeface.BOLD;
+ private static final int STYLE_ITALIC = Typeface.ITALIC;
+
+ private static final String TAG = "WebvttCueParser";
+
+ private final StringBuilder textBuilder;
+
+ public WebvttCueParser() {
+ textBuilder = new StringBuilder();
+ }
+
+ /**
+ * Parses the next valid WebVTT cue in a parsable array, including timestamps, settings and text.
+ *
+ * @param webvttData Parsable WebVTT file data.
+ * @param builder Builder for WebVTT Cues (output parameter).
+ * @param styles List of styles defined by the CSS style blocks preceding the cues.
+ * @return Whether a valid Cue was found.
+ */
+ public boolean parseCue(
+ ParsableByteArray webvttData, WebvttCue.Builder builder, List<WebvttCssStyle> styles) {
+ @Nullable String firstLine = webvttData.readLine();
+ if (firstLine == null) {
+ return false;
+ }
+ Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(firstLine);
+ if (cueHeaderMatcher.matches()) {
+ // We have found the timestamps in the first line. No id present.
+ return parseCue(null, cueHeaderMatcher, webvttData, builder, textBuilder, styles);
+ }
+ // The first line is not the timestamps, but could be the cue id.
+ @Nullable String secondLine = webvttData.readLine();
+ if (secondLine == null) {
+ return false;
+ }
+ cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(secondLine);
+ if (cueHeaderMatcher.matches()) {
+ // We can do the rest of the parsing, including the id.
+ return parseCue(firstLine.trim(), cueHeaderMatcher, webvttData, builder, textBuilder,
+ styles);
+ }
+ return false;
+ }
+
+ /**
+ * Parses a string containing a list of cue settings.
+ *
+ * @param cueSettingsList String containing the settings for a given cue.
+ * @param builder The {@link WebvttCue.Builder} where incremental construction takes place.
+ */
+ /* package */ static void parseCueSettingsList(String cueSettingsList,
+ WebvttCue.Builder builder) {
+ // Parse the cue settings list.
+ Matcher cueSettingMatcher = CUE_SETTING_PATTERN.matcher(cueSettingsList);
+ while (cueSettingMatcher.find()) {
+ String name = cueSettingMatcher.group(1);
+ String value = cueSettingMatcher.group(2);
+ try {
+ if ("line".equals(name)) {
+ parseLineAttribute(value, builder);
+ } else if ("align".equals(name)) {
+ builder.setTextAlignment(parseTextAlignment(value));
+ } else if ("position".equals(name)) {
+ parsePositionAttribute(value, builder);
+ } else if ("size".equals(name)) {
+ builder.setWidth(WebvttParserUtil.parsePercentage(value));
+ } else {
+ Log.w(TAG, "Unknown cue setting " + name + ":" + value);
+ }
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Skipping bad cue setting: " + cueSettingMatcher.group());
+ }
+ }
+ }
+
+ /**
+ * Parses the text payload of a WebVTT Cue and applies modifications on {@link WebvttCue.Builder}.
+ *
+ * @param id Id of the cue, {@code null} if it is not present.
+ * @param markup The markup text to be parsed.
+ * @param styles List of styles defined by the CSS style blocks preceding the cues.
+ * @param builder Output builder.
+ */
+ /* package */ static void parseCueText(
+ @Nullable String id, String markup, WebvttCue.Builder builder, List<WebvttCssStyle> styles) {
+ SpannableStringBuilder spannedText = new SpannableStringBuilder();
+ ArrayDeque<StartTag> startTagStack = new ArrayDeque<>();
+ List<StyleMatch> scratchStyleMatches = new ArrayList<>();
+ int pos = 0;
+ while (pos < markup.length()) {
+ char curr = markup.charAt(pos);
+ switch (curr) {
+ case CHAR_LESS_THAN:
+ if (pos + 1 >= markup.length()) {
+ pos++;
+ break; // avoid ArrayOutOfBoundsException
+ }
+ int ltPos = pos;
+ boolean isClosingTag = markup.charAt(ltPos + 1) == CHAR_SLASH;
+ pos = findEndOfTag(markup, ltPos + 1);
+ boolean isVoidTag = markup.charAt(pos - 2) == CHAR_SLASH;
+ String fullTagExpression = markup.substring(ltPos + (isClosingTag ? 2 : 1),
+ isVoidTag ? pos - 2 : pos - 1);
+ if (fullTagExpression.trim().isEmpty()) {
+ continue;
+ }
+ String tagName = getTagName(fullTagExpression);
+ if (!isSupportedTag(tagName)) {
+ continue;
+ }
+ if (isClosingTag) {
+ StartTag startTag;
+ do {
+ if (startTagStack.isEmpty()) {
+ break;
+ }
+ startTag = startTagStack.pop();
+ applySpansForTag(id, startTag, spannedText, styles, scratchStyleMatches);
+ } while(!startTag.name.equals(tagName));
+ } else if (!isVoidTag) {
+ startTagStack.push(StartTag.buildStartTag(fullTagExpression, spannedText.length()));
+ }
+ break;
+ case CHAR_AMPERSAND:
+ int semiColonEndIndex = markup.indexOf(CHAR_SEMI_COLON, pos + 1);
+ int spaceEndIndex = markup.indexOf(CHAR_SPACE, pos + 1);
+ int entityEndIndex = semiColonEndIndex == -1 ? spaceEndIndex
+ : (spaceEndIndex == -1 ? semiColonEndIndex
+ : Math.min(semiColonEndIndex, spaceEndIndex));
+ if (entityEndIndex != -1) {
+ applyEntity(markup.substring(pos + 1, entityEndIndex), spannedText);
+ if (entityEndIndex == spaceEndIndex) {
+ spannedText.append(" ");
+ }
+ pos = entityEndIndex + 1;
+ } else {
+ spannedText.append(curr);
+ pos++;
+ }
+ break;
+ default:
+ spannedText.append(curr);
+ pos++;
+ break;
+ }
+ }
+ // apply unclosed tags
+ while (!startTagStack.isEmpty()) {
+ applySpansForTag(id, startTagStack.pop(), spannedText, styles, scratchStyleMatches);
+ }
+ applySpansForTag(id, StartTag.buildWholeCueVirtualTag(), spannedText, styles,
+ scratchStyleMatches);
+ builder.setText(spannedText);
+ }
+
+ private static boolean parseCue(
+ @Nullable String id,
+ Matcher cueHeaderMatcher,
+ ParsableByteArray webvttData,
+ WebvttCue.Builder builder,
+ StringBuilder textBuilder,
+ List<WebvttCssStyle> styles) {
+ try {
+ // Parse the cue start and end times.
+ builder.setStartTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1)))
+ .setEndTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(2)));
+ } catch (NumberFormatException e) {
+ Log.w(TAG, "Skipping cue with bad header: " + cueHeaderMatcher.group());
+ return false;
+ }
+
+ parseCueSettingsList(cueHeaderMatcher.group(3), builder);
+
+ // Parse the cue text.
+ textBuilder.setLength(0);
+ for (String line = webvttData.readLine();
+ !TextUtils.isEmpty(line);
+ line = webvttData.readLine()) {
+ if (textBuilder.length() > 0) {
+ textBuilder.append("\n");
+ }
+ textBuilder.append(line.trim());
+ }
+ parseCueText(id, textBuilder.toString(), builder, styles);
+ return true;
+ }
+
+ // Internal methods
+
+ private static void parseLineAttribute(String s, WebvttCue.Builder builder) {
+ int commaIndex = s.indexOf(',');
+ if (commaIndex != -1) {
+ builder.setLineAnchor(parsePositionAnchor(s.substring(commaIndex + 1)));
+ s = s.substring(0, commaIndex);
+ }
+ if (s.endsWith("%")) {
+ builder.setLine(WebvttParserUtil.parsePercentage(s)).setLineType(Cue.LINE_TYPE_FRACTION);
+ } else {
+ int lineNumber = Integer.parseInt(s);
+ if (lineNumber < 0) {
+ // WebVTT defines line -1 as last visible row when lineAnchor is ANCHOR_TYPE_START, where-as
+ // Cue defines it to be the first row that's not visible.
+ lineNumber--;
+ }
+ builder.setLine(lineNumber).setLineType(Cue.LINE_TYPE_NUMBER);
+ }
+ }
+
+ private static void parsePositionAttribute(String s, WebvttCue.Builder builder) {
+ int commaIndex = s.indexOf(',');
+ if (commaIndex != -1) {
+ builder.setPositionAnchor(parsePositionAnchor(s.substring(commaIndex + 1)));
+ s = s.substring(0, commaIndex);
+ }
+ builder.setPosition(WebvttParserUtil.parsePercentage(s));
+ }
+
+ @Cue.AnchorType
+ private static int parsePositionAnchor(String s) {
+ switch (s) {
+ case "start":
+ return Cue.ANCHOR_TYPE_START;
+ case "center":
+ 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;
+ }
+ }
+
+ @WebvttCue.Builder.TextAlignment
+ private static int parseTextAlignment(String s) {
+ switch (s) {
+ case "start":
+ return WebvttCue.Builder.TEXT_ALIGNMENT_START;
+ case "left":
+ return WebvttCue.Builder.TEXT_ALIGNMENT_LEFT;
+ case "center":
+ case "middle":
+ return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER;
+ case "end":
+ return WebvttCue.Builder.TEXT_ALIGNMENT_END;
+ case "right":
+ return WebvttCue.Builder.TEXT_ALIGNMENT_RIGHT;
+ default:
+ Log.w(TAG, "Invalid alignment value: " + s);
+ // Default value: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment
+ return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER;
+ }
+ }
+
+ /**
+ * Find end of tag (&gt;). The position returned is the position of the &gt; plus one (exclusive).
+ *
+ * @param markup The WebVTT cue markup to be parsed.
+ * @param startPos The position from where to start searching for the end of tag.
+ * @return The position of the end of tag plus 1 (one).
+ */
+ private static int findEndOfTag(String markup, int startPos) {
+ int index = markup.indexOf(CHAR_GREATER_THAN, startPos);
+ return index == -1 ? markup.length() : index + 1;
+ }
+
+ private static void applyEntity(String entity, SpannableStringBuilder spannedText) {
+ switch (entity) {
+ case ENTITY_LESS_THAN:
+ spannedText.append('<');
+ break;
+ case ENTITY_GREATER_THAN:
+ spannedText.append('>');
+ break;
+ case ENTITY_NON_BREAK_SPACE:
+ spannedText.append(' ');
+ break;
+ case ENTITY_AMPERSAND:
+ spannedText.append('&');
+ break;
+ default:
+ Log.w(TAG, "ignoring unsupported entity: '&" + entity + ";'");
+ break;
+ }
+ }
+
+ private static boolean isSupportedTag(String tagName) {
+ switch (tagName) {
+ case TAG_BOLD:
+ case TAG_CLASS:
+ case TAG_ITALIC:
+ case TAG_LANG:
+ case TAG_UNDERLINE:
+ case TAG_VOICE:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static void applySpansForTag(
+ @Nullable String cueId,
+ StartTag startTag,
+ SpannableStringBuilder text,
+ List<WebvttCssStyle> styles,
+ List<StyleMatch> scratchStyleMatches) {
+ int start = startTag.position;
+ int end = text.length();
+ switch(startTag.name) {
+ case TAG_BOLD:
+ text.setSpan(new StyleSpan(STYLE_BOLD), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TAG_ITALIC:
+ text.setSpan(new StyleSpan(STYLE_ITALIC), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TAG_UNDERLINE:
+ text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case TAG_CLASS:
+ case TAG_LANG:
+ case TAG_VOICE:
+ case "": // Case of the "whole cue" virtual tag.
+ break;
+ default:
+ return;
+ }
+ scratchStyleMatches.clear();
+ getApplicableStyles(styles, cueId, startTag, scratchStyleMatches);
+ int styleMatchesCount = scratchStyleMatches.size();
+ for (int i = 0; i < styleMatchesCount; i++) {
+ applyStyleToText(text, scratchStyleMatches.get(i).style, start, end);
+ }
+ }
+
+ private static void applyStyleToText(SpannableStringBuilder spannedText, WebvttCssStyle style,
+ int start, int end) {
+ if (style == null) {
+ return;
+ }
+ if (style.getStyle() != WebvttCssStyle.UNSPECIFIED) {
+ spannedText.setSpan(new StyleSpan(style.getStyle()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.isLinethrough()) {
+ spannedText.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.isUnderline()) {
+ spannedText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.hasFontColor()) {
+ spannedText.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.hasBackgroundColor()) {
+ spannedText.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end,
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ if (style.getFontFamily() != null) {
+ spannedText.setSpan(new TypefaceSpan(style.getFontFamily()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ Layout.Alignment textAlign = style.getTextAlign();
+ if (textAlign != null) {
+ spannedText.setSpan(
+ new AlignmentSpan.Standard(textAlign), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ }
+ switch (style.getFontSizeUnit()) {
+ case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL:
+ spannedText.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case WebvttCssStyle.FONT_SIZE_UNIT_EM:
+ spannedText.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT:
+ spannedText.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end,
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+ break;
+ case WebvttCssStyle.UNSPECIFIED:
+ // Do nothing.
+ break;
+ }
+ }
+
+ /**
+ * Returns the tag name for the given tag contents.
+ *
+ * @param tagExpression Characters between &amp;lt: and &amp;gt; of a start or end tag.
+ * @return The name of tag.
+ */
+ private static String getTagName(String tagExpression) {
+ tagExpression = tagExpression.trim();
+ Assertions.checkArgument(!tagExpression.isEmpty());
+ return Util.splitAtFirst(tagExpression, "[ \\.]")[0];
+ }
+
+ private static void getApplicableStyles(
+ List<WebvttCssStyle> declaredStyles,
+ @Nullable String id,
+ StartTag tag,
+ List<StyleMatch> output) {
+ int styleCount = declaredStyles.size();
+ for (int i = 0; i < styleCount; i++) {
+ WebvttCssStyle style = declaredStyles.get(i);
+ int score = style.getSpecificityScore(id, tag.name, tag.classes, tag.voice);
+ if (score > 0) {
+ output.add(new StyleMatch(score, style));
+ }
+ }
+ Collections.sort(output);
+ }
+
+ private static final class StyleMatch implements Comparable<StyleMatch> {
+
+ public final int score;
+ public final WebvttCssStyle style;
+
+ public StyleMatch(int score, WebvttCssStyle style) {
+ this.score = score;
+ this.style = style;
+ }
+
+ @Override
+ public int compareTo(@NonNull StyleMatch another) {
+ return this.score - another.score;
+ }
+
+ }
+
+ private static final class StartTag {
+
+ private static final String[] NO_CLASSES = new String[0];
+
+ public final String name;
+ public final int position;
+ public final String voice;
+ public final String[] classes;
+
+ private StartTag(String name, int position, String voice, String[] classes) {
+ this.position = position;
+ this.name = name;
+ this.voice = voice;
+ this.classes = classes;
+ }
+
+ public static StartTag buildStartTag(String fullTagExpression, int position) {
+ fullTagExpression = fullTagExpression.trim();
+ Assertions.checkArgument(!fullTagExpression.isEmpty());
+ int voiceStartIndex = fullTagExpression.indexOf(" ");
+ String voice;
+ if (voiceStartIndex == -1) {
+ voice = "";
+ } else {
+ voice = fullTagExpression.substring(voiceStartIndex).trim();
+ fullTagExpression = fullTagExpression.substring(0, voiceStartIndex);
+ }
+ String[] nameAndClasses = Util.split(fullTagExpression, "\\.");
+ String name = nameAndClasses[0];
+ String[] classes;
+ if (nameAndClasses.length > 1) {
+ classes = Util.nullSafeArrayCopyOfRange(nameAndClasses, 1, nameAndClasses.length);
+ } else {
+ classes = NO_CLASSES;
+ }
+ return new StartTag(name, position, voice, classes);
+ }
+
+ public static StartTag buildWholeCueVirtualTag() {
+ return new StartTag("", 0, "", new String[0]);
+ }
+
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java
new file mode 100644
index 0000000000..a70a49e82e
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java
@@ -0,0 +1,125 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import android.text.TextUtils;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A {@link SimpleSubtitleDecoder} for WebVTT.
+ * <p>
+ * @see <a href="http://dev.w3.org/html5/webvtt">WebVTT specification</a>
+ */
+public final class WebvttDecoder extends SimpleSubtitleDecoder {
+
+ private static final int EVENT_NONE = -1;
+ private static final int EVENT_END_OF_FILE = 0;
+ private static final int EVENT_COMMENT = 1;
+ private static final int EVENT_STYLE_BLOCK = 2;
+ private static final int EVENT_CUE = 3;
+
+ private static final String COMMENT_START = "NOTE";
+ private static final String STYLE_START = "STYLE";
+
+ private final WebvttCueParser cueParser;
+ private final ParsableByteArray parsableWebvttData;
+ private final WebvttCue.Builder webvttCueBuilder;
+ private final CssParser cssParser;
+ private final List<WebvttCssStyle> definedStyles;
+
+ public WebvttDecoder() {
+ super("WebvttDecoder");
+ cueParser = new WebvttCueParser();
+ parsableWebvttData = new ParsableByteArray();
+ webvttCueBuilder = new WebvttCue.Builder();
+ cssParser = new CssParser();
+ definedStyles = new ArrayList<>();
+ }
+
+ @Override
+ protected Subtitle decode(byte[] bytes, int length, boolean reset)
+ throws SubtitleDecoderException {
+ parsableWebvttData.reset(bytes, length);
+ // Initialization for consistent starting state.
+ webvttCueBuilder.reset();
+ definedStyles.clear();
+
+ // Validate the first line of the header, and skip the remainder.
+ try {
+ WebvttParserUtil.validateWebvttHeaderLine(parsableWebvttData);
+ } catch (ParserException e) {
+ throw new SubtitleDecoderException(e);
+ }
+ while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {}
+
+ int event;
+ ArrayList<WebvttCue> subtitles = new ArrayList<>();
+ while ((event = getNextEvent(parsableWebvttData)) != EVENT_END_OF_FILE) {
+ if (event == EVENT_COMMENT) {
+ skipComment(parsableWebvttData);
+ } else if (event == EVENT_STYLE_BLOCK) {
+ if (!subtitles.isEmpty()) {
+ throw new SubtitleDecoderException("A style block was found after the first cue.");
+ }
+ parsableWebvttData.readLine(); // Consume the "STYLE" header.
+ definedStyles.addAll(cssParser.parseBlock(parsableWebvttData));
+ } else if (event == EVENT_CUE) {
+ if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder, definedStyles)) {
+ subtitles.add(webvttCueBuilder.build());
+ webvttCueBuilder.reset();
+ }
+ }
+ }
+ return new WebvttSubtitle(subtitles);
+ }
+
+ /**
+ * Positions the input right before the next event, and returns the kind of event found. Does not
+ * consume any data from such event, if any.
+ *
+ * @return The kind of event found.
+ */
+ private static int getNextEvent(ParsableByteArray parsableWebvttData) {
+ int foundEvent = EVENT_NONE;
+ int currentInputPosition = 0;
+ while (foundEvent == EVENT_NONE) {
+ currentInputPosition = parsableWebvttData.getPosition();
+ String line = parsableWebvttData.readLine();
+ if (line == null) {
+ foundEvent = EVENT_END_OF_FILE;
+ } else if (STYLE_START.equals(line)) {
+ foundEvent = EVENT_STYLE_BLOCK;
+ } else if (line.startsWith(COMMENT_START)) {
+ foundEvent = EVENT_COMMENT;
+ } else {
+ foundEvent = EVENT_CUE;
+ }
+ }
+ parsableWebvttData.setPosition(currentInputPosition);
+ return foundEvent;
+ }
+
+ private static void skipComment(ParsableByteArray parsableWebvttData) {
+ while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {}
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java
new file mode 100644
index 0000000000..b87d014de0
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java
@@ -0,0 +1,119 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import androidx.annotation.Nullable;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility methods for parsing WebVTT data.
+ */
+public final class WebvttParserUtil {
+
+ private static final Pattern COMMENT = Pattern.compile("^NOTE([ \t].*)?$");
+ private static final String WEBVTT_HEADER = "WEBVTT";
+
+ private WebvttParserUtil() {}
+
+ /**
+ * Reads and validates the first line of a WebVTT file.
+ *
+ * @param input The input from which the line should be read.
+ * @throws ParserException If the line isn't the start of a valid WebVTT file.
+ */
+ public static void validateWebvttHeaderLine(ParsableByteArray input) throws ParserException {
+ int startPosition = input.getPosition();
+ if (!isWebvttHeaderLine(input)) {
+ input.setPosition(startPosition);
+ throw new ParserException("Expected WEBVTT. Got " + input.readLine());
+ }
+ }
+
+ /**
+ * Returns whether the given input is the first line of a WebVTT file.
+ *
+ * @param input The input from which the line should be read.
+ */
+ public static boolean isWebvttHeaderLine(ParsableByteArray input) {
+ @Nullable String line = input.readLine();
+ return line != null && line.startsWith(WEBVTT_HEADER);
+ }
+
+ /**
+ * Parses a WebVTT timestamp.
+ *
+ * @param timestamp The timestamp string.
+ * @return The parsed timestamp in microseconds.
+ * @throws NumberFormatException If the timestamp could not be parsed.
+ */
+ public static long parseTimestampUs(String timestamp) throws NumberFormatException {
+ long value = 0;
+ String[] parts = Util.splitAtFirst(timestamp, "\\.");
+ String[] subparts = Util.split(parts[0], ":");
+ for (String subpart : subparts) {
+ value = (value * 60) + Long.parseLong(subpart);
+ }
+ value *= 1000;
+ if (parts.length == 2) {
+ value += Long.parseLong(parts[1]);
+ }
+ return value * 1000;
+ }
+
+ /**
+ * Parses a percentage string.
+ *
+ * @param s The percentage string.
+ * @return The parsed value, where 1.0 represents 100%.
+ * @throws NumberFormatException If the percentage could not be parsed.
+ */
+ public static float parsePercentage(String s) throws NumberFormatException {
+ if (!s.endsWith("%")) {
+ throw new NumberFormatException("Percentages must end with %");
+ }
+ return Float.parseFloat(s.substring(0, s.length() - 1)) / 100;
+ }
+
+ /**
+ * Reads lines up to and including the next WebVTT cue header.
+ *
+ * @param input The input from which lines should be read.
+ * @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was
+ * reached without a cue header being found. In the case that a cue header is found, groups 1,
+ * 2 and 3 of the returned matcher contain the start time, end time and settings list.
+ */
+ @Nullable
+ public static Matcher findNextCueHeader(ParsableByteArray input) {
+ @Nullable String line;
+ while ((line = input.readLine()) != null) {
+ if (COMMENT.matcher(line).matches()) {
+ // Skip until the end of the comment block.
+ while ((line = input.readLine()) != null && !line.isEmpty()) {}
+ } else {
+ Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(line);
+ if (cueHeaderMatcher.matches()) {
+ return cueHeaderMatcher;
+ }
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java
new file mode 100644
index 0000000000..558c699eba
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java
@@ -0,0 +1,115 @@
+/*
+ * 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 org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import android.text.SpannableStringBuilder;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.C;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions;
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A representation of a WebVTT subtitle.
+ */
+/* package */ final class WebvttSubtitle implements Subtitle {
+
+ private final List<WebvttCue> cues;
+ private final int numCues;
+ private final long[] cueTimesUs;
+ private final long[] sortedCueTimesUs;
+
+ /**
+ * @param cues A list of the cues in this subtitle.
+ */
+ public WebvttSubtitle(List<WebvttCue> cues) {
+ this.cues = cues;
+ numCues = cues.size();
+ cueTimesUs = new long[2 * numCues];
+ for (int cueIndex = 0; cueIndex < numCues; cueIndex++) {
+ WebvttCue cue = cues.get(cueIndex);
+ int arrayIndex = cueIndex * 2;
+ cueTimesUs[arrayIndex] = cue.startTime;
+ cueTimesUs[arrayIndex + 1] = cue.endTime;
+ }
+ sortedCueTimesUs = Arrays.copyOf(cueTimesUs, cueTimesUs.length);
+ Arrays.sort(sortedCueTimesUs);
+ }
+
+ @Override
+ public int getNextEventTimeIndex(long timeUs) {
+ int index = Util.binarySearchCeil(sortedCueTimesUs, timeUs, false, false);
+ return index < sortedCueTimesUs.length ? index : C.INDEX_UNSET;
+ }
+
+ @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 List<Cue> getCues(long timeUs) {
+ List<Cue> list = new ArrayList<>();
+ WebvttCue firstNormalCue = null;
+ SpannableStringBuilder normalCueTextBuilder = null;
+
+ for (int i = 0; i < numCues; i++) {
+ if ((cueTimesUs[i * 2] <= timeUs) && (timeUs < cueTimesUs[i * 2 + 1])) {
+ WebvttCue cue = cues.get(i);
+ // TODO(ibaker): Replace this with a closer implementation of the WebVTT spec (keeping
+ // individual cues, but tweaking their `line` value):
+ // https://www.w3.org/TR/webvtt1/#cue-computed-line
+ 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(Assertions.checkNotNull(firstNormalCue.text))
+ .append("\n")
+ .append(Assertions.checkNotNull(cue.text));
+ } else {
+ normalCueTextBuilder.append("\n").append(Assertions.checkNotNull(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 WebvttCue.Builder().setText(normalCueTextBuilder).build());
+ } else if (firstNormalCue != null) {
+ // there was only a single normal cue, so just add it to the list
+ list.add(firstNormalCue);
+ }
+ return list;
+ }
+
+}
diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java
new file mode 100644
index 0000000000..e2c014d539
--- /dev/null
+++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2019 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.
+ *
+ */
+@NonNullApi
+package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt;
+
+import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi;