summaryrefslogtreecommitdiffstats
path: root/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa')
-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
5 files changed, 920 insertions, 0 deletions
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;