diff options
Diffstat (limited to 'mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text')
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 (>). The position returned is the position of the > 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 &lt: and &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; |