Integer == is dangerous, as equal objects may not be identical objects. In fact, MediaFormat.setInteger was creating a new object every time. Change MediaFormat.setInteger and setLong to use valueOf, which may reuse returned objects. Change-Id: Iedcc6003adbf05c0c870aa4b3ada7f181a5b870e
1867 lines
64 KiB
Java
1867 lines
64 KiB
Java
/*
|
|
* Copyright (C) 2013 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 android.media;
|
|
|
|
import android.content.Context;
|
|
import android.text.Layout.Alignment;
|
|
import android.text.SpannableStringBuilder;
|
|
import android.util.ArrayMap;
|
|
import android.util.AttributeSet;
|
|
import android.util.Log;
|
|
import android.view.Gravity;
|
|
import android.view.View;
|
|
import android.view.ViewGroup;
|
|
import android.view.accessibility.CaptioningManager;
|
|
import android.view.accessibility.CaptioningManager.CaptionStyle;
|
|
import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
|
|
import android.widget.LinearLayout;
|
|
|
|
import com.android.internal.widget.SubtitleView;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.HashMap;
|
|
import java.util.Map;
|
|
import java.util.Vector;
|
|
|
|
/** @hide */
|
|
public class WebVttRenderer extends SubtitleController.Renderer {
|
|
private final Context mContext;
|
|
|
|
private WebVttRenderingWidget mRenderingWidget;
|
|
|
|
public WebVttRenderer(Context context) {
|
|
mContext = context;
|
|
}
|
|
|
|
@Override
|
|
public boolean supports(MediaFormat format) {
|
|
if (format.containsKey(MediaFormat.KEY_MIME)) {
|
|
return format.getString(MediaFormat.KEY_MIME).equals("text/vtt");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public SubtitleTrack createTrack(MediaFormat format) {
|
|
if (mRenderingWidget == null) {
|
|
mRenderingWidget = new WebVttRenderingWidget(mContext);
|
|
}
|
|
|
|
return new WebVttTrack(mRenderingWidget, format);
|
|
}
|
|
}
|
|
|
|
/** @hide */
|
|
class TextTrackCueSpan {
|
|
long mTimestampMs;
|
|
boolean mEnabled;
|
|
String mText;
|
|
TextTrackCueSpan(String text, long timestamp) {
|
|
mTimestampMs = timestamp;
|
|
mText = text;
|
|
// spans with timestamp will be enabled by Cue.onTime
|
|
mEnabled = (mTimestampMs < 0);
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (!(o instanceof TextTrackCueSpan)) {
|
|
return false;
|
|
}
|
|
TextTrackCueSpan span = (TextTrackCueSpan) o;
|
|
return mTimestampMs == span.mTimestampMs &&
|
|
mText.equals(span.mText);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @hide
|
|
*
|
|
* Extract all text without style, but with timestamp spans.
|
|
*/
|
|
class UnstyledTextExtractor implements Tokenizer.OnTokenListener {
|
|
StringBuilder mLine = new StringBuilder();
|
|
Vector<TextTrackCueSpan[]> mLines = new Vector<TextTrackCueSpan[]>();
|
|
Vector<TextTrackCueSpan> mCurrentLine = new Vector<TextTrackCueSpan>();
|
|
long mLastTimestamp;
|
|
|
|
UnstyledTextExtractor() {
|
|
init();
|
|
}
|
|
|
|
private void init() {
|
|
mLine.delete(0, mLine.length());
|
|
mLines.clear();
|
|
mCurrentLine.clear();
|
|
mLastTimestamp = -1;
|
|
}
|
|
|
|
@Override
|
|
public void onData(String s) {
|
|
mLine.append(s);
|
|
}
|
|
|
|
@Override
|
|
public void onStart(String tag, String[] classes, String annotation) { }
|
|
|
|
@Override
|
|
public void onEnd(String tag) { }
|
|
|
|
@Override
|
|
public void onTimeStamp(long timestampMs) {
|
|
// finish any prior span
|
|
if (mLine.length() > 0 && timestampMs != mLastTimestamp) {
|
|
mCurrentLine.add(
|
|
new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
|
|
mLine.delete(0, mLine.length());
|
|
}
|
|
mLastTimestamp = timestampMs;
|
|
}
|
|
|
|
@Override
|
|
public void onLineEnd() {
|
|
// finish any pending span
|
|
if (mLine.length() > 0) {
|
|
mCurrentLine.add(
|
|
new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
|
|
mLine.delete(0, mLine.length());
|
|
}
|
|
|
|
TextTrackCueSpan[] spans = new TextTrackCueSpan[mCurrentLine.size()];
|
|
mCurrentLine.toArray(spans);
|
|
mCurrentLine.clear();
|
|
mLines.add(spans);
|
|
}
|
|
|
|
public TextTrackCueSpan[][] getText() {
|
|
// for politeness, finish last cue-line if it ends abruptly
|
|
if (mLine.length() > 0 || mCurrentLine.size() > 0) {
|
|
onLineEnd();
|
|
}
|
|
TextTrackCueSpan[][] lines = new TextTrackCueSpan[mLines.size()][];
|
|
mLines.toArray(lines);
|
|
init();
|
|
return lines;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @hide
|
|
*
|
|
* Tokenizer tokenizes the WebVTT Cue Text into tags and data
|
|
*/
|
|
class Tokenizer {
|
|
private static final String TAG = "Tokenizer";
|
|
private TokenizerPhase mPhase;
|
|
private TokenizerPhase mDataTokenizer;
|
|
private TokenizerPhase mTagTokenizer;
|
|
|
|
private OnTokenListener mListener;
|
|
private String mLine;
|
|
private int mHandledLen;
|
|
|
|
interface TokenizerPhase {
|
|
TokenizerPhase start();
|
|
void tokenize();
|
|
}
|
|
|
|
class DataTokenizer implements TokenizerPhase {
|
|
// includes both WebVTT data && escape state
|
|
private StringBuilder mData;
|
|
|
|
public TokenizerPhase start() {
|
|
mData = new StringBuilder();
|
|
return this;
|
|
}
|
|
|
|
private boolean replaceEscape(String escape, String replacement, int pos) {
|
|
if (mLine.startsWith(escape, pos)) {
|
|
mData.append(mLine.substring(mHandledLen, pos));
|
|
mData.append(replacement);
|
|
mHandledLen = pos + escape.length();
|
|
pos = mHandledLen - 1;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public void tokenize() {
|
|
int end = mLine.length();
|
|
for (int pos = mHandledLen; pos < mLine.length(); pos++) {
|
|
if (mLine.charAt(pos) == '&') {
|
|
if (replaceEscape("&", "&", pos) ||
|
|
replaceEscape("<", "<", pos) ||
|
|
replaceEscape(">", ">", pos) ||
|
|
replaceEscape("‎", "\u200e", pos) ||
|
|
replaceEscape("‏", "\u200f", pos) ||
|
|
replaceEscape(" ", "\u00a0", pos)) {
|
|
continue;
|
|
}
|
|
} else if (mLine.charAt(pos) == '<') {
|
|
end = pos;
|
|
mPhase = mTagTokenizer.start();
|
|
break;
|
|
}
|
|
}
|
|
mData.append(mLine.substring(mHandledLen, end));
|
|
// yield mData
|
|
mListener.onData(mData.toString());
|
|
mData.delete(0, mData.length());
|
|
mHandledLen = end;
|
|
}
|
|
}
|
|
|
|
class TagTokenizer implements TokenizerPhase {
|
|
private boolean mAtAnnotation;
|
|
private String mName, mAnnotation;
|
|
|
|
public TokenizerPhase start() {
|
|
mName = mAnnotation = "";
|
|
mAtAnnotation = false;
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public void tokenize() {
|
|
if (!mAtAnnotation)
|
|
mHandledLen++;
|
|
if (mHandledLen < mLine.length()) {
|
|
String[] parts;
|
|
/**
|
|
* Collect annotations and end-tags to closing >. Collect tag
|
|
* name to closing bracket or next white-space.
|
|
*/
|
|
if (mAtAnnotation || mLine.charAt(mHandledLen) == '/') {
|
|
parts = mLine.substring(mHandledLen).split(">");
|
|
} else {
|
|
parts = mLine.substring(mHandledLen).split("[\t\f >]");
|
|
}
|
|
String part = mLine.substring(
|
|
mHandledLen, mHandledLen + parts[0].length());
|
|
mHandledLen += parts[0].length();
|
|
|
|
if (mAtAnnotation) {
|
|
mAnnotation += " " + part;
|
|
} else {
|
|
mName = part;
|
|
}
|
|
}
|
|
|
|
mAtAnnotation = true;
|
|
|
|
if (mHandledLen < mLine.length() && mLine.charAt(mHandledLen) == '>') {
|
|
yield_tag();
|
|
mPhase = mDataTokenizer.start();
|
|
mHandledLen++;
|
|
}
|
|
}
|
|
|
|
private void yield_tag() {
|
|
if (mName.startsWith("/")) {
|
|
mListener.onEnd(mName.substring(1));
|
|
} else if (mName.length() > 0 && Character.isDigit(mName.charAt(0))) {
|
|
// timestamp
|
|
try {
|
|
long timestampMs = WebVttParser.parseTimestampMs(mName);
|
|
mListener.onTimeStamp(timestampMs);
|
|
} catch (NumberFormatException e) {
|
|
Log.d(TAG, "invalid timestamp tag: <" + mName + ">");
|
|
}
|
|
} else {
|
|
mAnnotation = mAnnotation.replaceAll("\\s+", " ");
|
|
if (mAnnotation.startsWith(" ")) {
|
|
mAnnotation = mAnnotation.substring(1);
|
|
}
|
|
if (mAnnotation.endsWith(" ")) {
|
|
mAnnotation = mAnnotation.substring(0, mAnnotation.length() - 1);
|
|
}
|
|
|
|
String[] classes = null;
|
|
int dotAt = mName.indexOf('.');
|
|
if (dotAt >= 0) {
|
|
classes = mName.substring(dotAt + 1).split("\\.");
|
|
mName = mName.substring(0, dotAt);
|
|
}
|
|
mListener.onStart(mName, classes, mAnnotation);
|
|
}
|
|
}
|
|
}
|
|
|
|
Tokenizer(OnTokenListener listener) {
|
|
mDataTokenizer = new DataTokenizer();
|
|
mTagTokenizer = new TagTokenizer();
|
|
reset();
|
|
mListener = listener;
|
|
}
|
|
|
|
void reset() {
|
|
mPhase = mDataTokenizer.start();
|
|
}
|
|
|
|
void tokenize(String s) {
|
|
mHandledLen = 0;
|
|
mLine = s;
|
|
while (mHandledLen < mLine.length()) {
|
|
mPhase.tokenize();
|
|
}
|
|
/* we are finished with a line unless we are in the middle of a tag */
|
|
if (!(mPhase instanceof TagTokenizer)) {
|
|
// yield END-OF-LINE
|
|
mListener.onLineEnd();
|
|
}
|
|
}
|
|
|
|
interface OnTokenListener {
|
|
void onData(String s);
|
|
void onStart(String tag, String[] classes, String annotation);
|
|
void onEnd(String tag);
|
|
void onTimeStamp(long timestampMs);
|
|
void onLineEnd();
|
|
}
|
|
}
|
|
|
|
/** @hide */
|
|
class TextTrackRegion {
|
|
final static int SCROLL_VALUE_NONE = 300;
|
|
final static int SCROLL_VALUE_SCROLL_UP = 301;
|
|
|
|
String mId;
|
|
float mWidth;
|
|
int mLines;
|
|
float mAnchorPointX, mAnchorPointY;
|
|
float mViewportAnchorPointX, mViewportAnchorPointY;
|
|
int mScrollValue;
|
|
|
|
TextTrackRegion() {
|
|
mId = "";
|
|
mWidth = 100;
|
|
mLines = 3;
|
|
mAnchorPointX = mViewportAnchorPointX = 0.f;
|
|
mAnchorPointY = mViewportAnchorPointY = 100.f;
|
|
mScrollValue = SCROLL_VALUE_NONE;
|
|
}
|
|
|
|
public String toString() {
|
|
StringBuilder res = new StringBuilder(" {id:\"").append(mId)
|
|
.append("\", width:").append(mWidth)
|
|
.append(", lines:").append(mLines)
|
|
.append(", anchorPoint:(").append(mAnchorPointX)
|
|
.append(", ").append(mAnchorPointY)
|
|
.append("), viewportAnchorPoints:").append(mViewportAnchorPointX)
|
|
.append(", ").append(mViewportAnchorPointY)
|
|
.append("), scrollValue:")
|
|
.append(mScrollValue == SCROLL_VALUE_NONE ? "none" :
|
|
mScrollValue == SCROLL_VALUE_SCROLL_UP ? "scroll_up" :
|
|
"INVALID")
|
|
.append("}");
|
|
return res.toString();
|
|
}
|
|
}
|
|
|
|
/** @hide */
|
|
class TextTrackCue extends SubtitleTrack.Cue {
|
|
final static int WRITING_DIRECTION_HORIZONTAL = 100;
|
|
final static int WRITING_DIRECTION_VERTICAL_RL = 101;
|
|
final static int WRITING_DIRECTION_VERTICAL_LR = 102;
|
|
|
|
final static int ALIGNMENT_MIDDLE = 200;
|
|
final static int ALIGNMENT_START = 201;
|
|
final static int ALIGNMENT_END = 202;
|
|
final static int ALIGNMENT_LEFT = 203;
|
|
final static int ALIGNMENT_RIGHT = 204;
|
|
private static final String TAG = "TTCue";
|
|
|
|
String mId;
|
|
boolean mPauseOnExit;
|
|
int mWritingDirection;
|
|
String mRegionId;
|
|
boolean mSnapToLines;
|
|
Integer mLinePosition; // null means AUTO
|
|
boolean mAutoLinePosition;
|
|
int mTextPosition;
|
|
int mSize;
|
|
int mAlignment;
|
|
// Vector<String> mText;
|
|
String[] mStrings;
|
|
TextTrackCueSpan[][] mLines;
|
|
TextTrackRegion mRegion;
|
|
|
|
TextTrackCue() {
|
|
mId = "";
|
|
mPauseOnExit = false;
|
|
mWritingDirection = WRITING_DIRECTION_HORIZONTAL;
|
|
mRegionId = "";
|
|
mSnapToLines = true;
|
|
mLinePosition = null /* AUTO */;
|
|
mTextPosition = 50;
|
|
mSize = 100;
|
|
mAlignment = ALIGNMENT_MIDDLE;
|
|
mLines = null;
|
|
mRegion = null;
|
|
}
|
|
|
|
@Override
|
|
public boolean equals(Object o) {
|
|
if (!(o instanceof TextTrackCue)) {
|
|
return false;
|
|
}
|
|
if (this == o) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
TextTrackCue cue = (TextTrackCue) o;
|
|
boolean res = mId.equals(cue.mId) &&
|
|
mPauseOnExit == cue.mPauseOnExit &&
|
|
mWritingDirection == cue.mWritingDirection &&
|
|
mRegionId.equals(cue.mRegionId) &&
|
|
mSnapToLines == cue.mSnapToLines &&
|
|
mAutoLinePosition == cue.mAutoLinePosition &&
|
|
(mAutoLinePosition ||
|
|
((mLinePosition != null && mLinePosition.equals(cue.mLinePosition)) ||
|
|
(mLinePosition == null && cue.mLinePosition == null))) &&
|
|
mTextPosition == cue.mTextPosition &&
|
|
mSize == cue.mSize &&
|
|
mAlignment == cue.mAlignment &&
|
|
mLines.length == cue.mLines.length;
|
|
if (res == true) {
|
|
for (int line = 0; line < mLines.length; line++) {
|
|
if (!Arrays.equals(mLines[line], cue.mLines[line])) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return res;
|
|
} catch(IncompatibleClassChangeError e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public StringBuilder appendStringsToBuilder(StringBuilder builder) {
|
|
if (mStrings == null) {
|
|
builder.append("null");
|
|
} else {
|
|
builder.append("[");
|
|
boolean first = true;
|
|
for (String s: mStrings) {
|
|
if (!first) {
|
|
builder.append(", ");
|
|
}
|
|
if (s == null) {
|
|
builder.append("null");
|
|
} else {
|
|
builder.append("\"");
|
|
builder.append(s);
|
|
builder.append("\"");
|
|
}
|
|
first = false;
|
|
}
|
|
builder.append("]");
|
|
}
|
|
return builder;
|
|
}
|
|
|
|
public StringBuilder appendLinesToBuilder(StringBuilder builder) {
|
|
if (mLines == null) {
|
|
builder.append("null");
|
|
} else {
|
|
builder.append("[");
|
|
boolean first = true;
|
|
for (TextTrackCueSpan[] spans: mLines) {
|
|
if (!first) {
|
|
builder.append(", ");
|
|
}
|
|
if (spans == null) {
|
|
builder.append("null");
|
|
} else {
|
|
builder.append("\"");
|
|
boolean innerFirst = true;
|
|
long lastTimestamp = -1;
|
|
for (TextTrackCueSpan span: spans) {
|
|
if (!innerFirst) {
|
|
builder.append(" ");
|
|
}
|
|
if (span.mTimestampMs != lastTimestamp) {
|
|
builder.append("<")
|
|
.append(WebVttParser.timeToString(
|
|
span.mTimestampMs))
|
|
.append(">");
|
|
lastTimestamp = span.mTimestampMs;
|
|
}
|
|
builder.append(span.mText);
|
|
innerFirst = false;
|
|
}
|
|
builder.append("\"");
|
|
}
|
|
first = false;
|
|
}
|
|
builder.append("]");
|
|
}
|
|
return builder;
|
|
}
|
|
|
|
public String toString() {
|
|
StringBuilder res = new StringBuilder();
|
|
|
|
res.append(WebVttParser.timeToString(mStartTimeMs))
|
|
.append(" --> ").append(WebVttParser.timeToString(mEndTimeMs))
|
|
.append(" {id:\"").append(mId)
|
|
.append("\", pauseOnExit:").append(mPauseOnExit)
|
|
.append(", direction:")
|
|
.append(mWritingDirection == WRITING_DIRECTION_HORIZONTAL ? "horizontal" :
|
|
mWritingDirection == WRITING_DIRECTION_VERTICAL_LR ? "vertical_lr" :
|
|
mWritingDirection == WRITING_DIRECTION_VERTICAL_RL ? "vertical_rl" :
|
|
"INVALID")
|
|
.append(", regionId:\"").append(mRegionId)
|
|
.append("\", snapToLines:").append(mSnapToLines)
|
|
.append(", linePosition:").append(mAutoLinePosition ? "auto" :
|
|
mLinePosition)
|
|
.append(", textPosition:").append(mTextPosition)
|
|
.append(", size:").append(mSize)
|
|
.append(", alignment:")
|
|
.append(mAlignment == ALIGNMENT_END ? "end" :
|
|
mAlignment == ALIGNMENT_LEFT ? "left" :
|
|
mAlignment == ALIGNMENT_MIDDLE ? "middle" :
|
|
mAlignment == ALIGNMENT_RIGHT ? "right" :
|
|
mAlignment == ALIGNMENT_START ? "start" : "INVALID")
|
|
.append(", text:");
|
|
appendStringsToBuilder(res).append("}");
|
|
return res.toString();
|
|
}
|
|
|
|
@Override
|
|
public int hashCode() {
|
|
return toString().hashCode();
|
|
}
|
|
|
|
@Override
|
|
public void onTime(long timeMs) {
|
|
for (TextTrackCueSpan[] line: mLines) {
|
|
for (TextTrackCueSpan span: line) {
|
|
span.mEnabled = timeMs >= span.mTimestampMs;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Supporting July 10 2013 draft version
|
|
*
|
|
* @hide
|
|
*/
|
|
class WebVttParser {
|
|
private static final String TAG = "WebVttParser";
|
|
private Phase mPhase;
|
|
private TextTrackCue mCue;
|
|
private Vector<String> mCueTexts;
|
|
private WebVttCueListener mListener;
|
|
private String mBuffer;
|
|
|
|
WebVttParser(WebVttCueListener listener) {
|
|
mPhase = mParseStart;
|
|
mBuffer = ""; /* mBuffer contains up to 1 incomplete line */
|
|
mListener = listener;
|
|
mCueTexts = new Vector<String>();
|
|
}
|
|
|
|
/* parsePercentageString */
|
|
public static float parseFloatPercentage(String s)
|
|
throws NumberFormatException {
|
|
if (!s.endsWith("%")) {
|
|
throw new NumberFormatException("does not end in %");
|
|
}
|
|
s = s.substring(0, s.length() - 1);
|
|
// parseFloat allows an exponent or a sign
|
|
if (s.matches(".*[^0-9.].*")) {
|
|
throw new NumberFormatException("contains an invalid character");
|
|
}
|
|
|
|
try {
|
|
float value = Float.parseFloat(s);
|
|
if (value < 0.0f || value > 100.0f) {
|
|
throw new NumberFormatException("is out of range");
|
|
}
|
|
return value;
|
|
} catch (NumberFormatException e) {
|
|
throw new NumberFormatException("is not a number");
|
|
}
|
|
}
|
|
|
|
public static int parseIntPercentage(String s) throws NumberFormatException {
|
|
if (!s.endsWith("%")) {
|
|
throw new NumberFormatException("does not end in %");
|
|
}
|
|
s = s.substring(0, s.length() - 1);
|
|
// parseInt allows "-0" that returns 0, so check for non-digits
|
|
if (s.matches(".*[^0-9].*")) {
|
|
throw new NumberFormatException("contains an invalid character");
|
|
}
|
|
|
|
try {
|
|
int value = Integer.parseInt(s);
|
|
if (value < 0 || value > 100) {
|
|
throw new NumberFormatException("is out of range");
|
|
}
|
|
return value;
|
|
} catch (NumberFormatException e) {
|
|
throw new NumberFormatException("is not a number");
|
|
}
|
|
}
|
|
|
|
public static long parseTimestampMs(String s) throws NumberFormatException {
|
|
if (!s.matches("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}")) {
|
|
throw new NumberFormatException("has invalid format");
|
|
}
|
|
|
|
String[] parts = s.split("\\.", 2);
|
|
long value = 0;
|
|
for (String group: parts[0].split(":")) {
|
|
value = value * 60 + Long.parseLong(group);
|
|
}
|
|
return value * 1000 + Long.parseLong(parts[1]);
|
|
}
|
|
|
|
public static String timeToString(long timeMs) {
|
|
return String.format("%d:%02d:%02d.%03d",
|
|
timeMs / 3600000, (timeMs / 60000) % 60,
|
|
(timeMs / 1000) % 60, timeMs % 1000);
|
|
}
|
|
|
|
public void parse(String s) {
|
|
boolean trailingCR = false;
|
|
mBuffer = (mBuffer + s.replace("\0", "\ufffd")).replace("\r\n", "\n");
|
|
|
|
/* keep trailing '\r' in case matching '\n' arrives in next packet */
|
|
if (mBuffer.endsWith("\r")) {
|
|
trailingCR = true;
|
|
mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
|
|
}
|
|
|
|
String[] lines = mBuffer.split("[\r\n]");
|
|
for (int i = 0; i < lines.length - 1; i++) {
|
|
mPhase.parse(lines[i]);
|
|
}
|
|
|
|
mBuffer = lines[lines.length - 1];
|
|
if (trailingCR)
|
|
mBuffer += "\r";
|
|
}
|
|
|
|
public void eos() {
|
|
if (mBuffer.endsWith("\r")) {
|
|
mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
|
|
}
|
|
|
|
mPhase.parse(mBuffer);
|
|
mBuffer = "";
|
|
|
|
yieldCue();
|
|
mPhase = mParseStart;
|
|
}
|
|
|
|
public void yieldCue() {
|
|
if (mCue != null && mCueTexts.size() > 0) {
|
|
mCue.mStrings = new String[mCueTexts.size()];
|
|
mCueTexts.toArray(mCue.mStrings);
|
|
mCueTexts.clear();
|
|
mListener.onCueParsed(mCue);
|
|
}
|
|
mCue = null;
|
|
}
|
|
|
|
interface Phase {
|
|
void parse(String line);
|
|
}
|
|
|
|
final private Phase mSkipRest = new Phase() {
|
|
@Override
|
|
public void parse(String line) { }
|
|
};
|
|
|
|
final private Phase mParseStart = new Phase() { // 5-9
|
|
@Override
|
|
public void parse(String line) {
|
|
if (line.startsWith("\ufeff")) {
|
|
line = line.substring(1);
|
|
}
|
|
if (!line.equals("WEBVTT") &&
|
|
!line.startsWith("WEBVTT ") &&
|
|
!line.startsWith("WEBVTT\t")) {
|
|
log_warning("Not a WEBVTT header", line);
|
|
mPhase = mSkipRest;
|
|
} else {
|
|
mPhase = mParseHeader;
|
|
}
|
|
}
|
|
};
|
|
|
|
final private Phase mParseHeader = new Phase() { // 10-13
|
|
TextTrackRegion parseRegion(String s) {
|
|
TextTrackRegion region = new TextTrackRegion();
|
|
for (String setting: s.split(" +")) {
|
|
int equalAt = setting.indexOf('=');
|
|
if (equalAt <= 0 || equalAt == setting.length() - 1) {
|
|
continue;
|
|
}
|
|
|
|
String name = setting.substring(0, equalAt);
|
|
String value = setting.substring(equalAt + 1);
|
|
if (name.equals("id")) {
|
|
region.mId = value;
|
|
} else if (name.equals("width")) {
|
|
try {
|
|
region.mWidth = parseFloatPercentage(value);
|
|
} catch (NumberFormatException e) {
|
|
log_warning("region setting", name,
|
|
"has invalid value", e.getMessage(), value);
|
|
}
|
|
} else if (name.equals("lines")) {
|
|
if (value.matches(".*[^0-9].*")) {
|
|
log_warning("lines", name, "contains an invalid character", value);
|
|
} else {
|
|
try {
|
|
region.mLines = Integer.parseInt(value);
|
|
assert(region.mLines >= 0); // lines contains only digits
|
|
} catch (NumberFormatException e) {
|
|
log_warning("region setting", name, "is not numeric", value);
|
|
}
|
|
}
|
|
} else if (name.equals("regionanchor") ||
|
|
name.equals("viewportanchor")) {
|
|
int commaAt = value.indexOf(",");
|
|
if (commaAt < 0) {
|
|
log_warning("region setting", name, "contains no comma", value);
|
|
continue;
|
|
}
|
|
|
|
String anchorX = value.substring(0, commaAt);
|
|
String anchorY = value.substring(commaAt + 1);
|
|
float x, y;
|
|
|
|
try {
|
|
x = parseFloatPercentage(anchorX);
|
|
} catch (NumberFormatException e) {
|
|
log_warning("region setting", name,
|
|
"has invalid x component", e.getMessage(), anchorX);
|
|
continue;
|
|
}
|
|
try {
|
|
y = parseFloatPercentage(anchorY);
|
|
} catch (NumberFormatException e) {
|
|
log_warning("region setting", name,
|
|
"has invalid y component", e.getMessage(), anchorY);
|
|
continue;
|
|
}
|
|
|
|
if (name.charAt(0) == 'r') {
|
|
region.mAnchorPointX = x;
|
|
region.mAnchorPointY = y;
|
|
} else {
|
|
region.mViewportAnchorPointX = x;
|
|
region.mViewportAnchorPointY = y;
|
|
}
|
|
} else if (name.equals("scroll")) {
|
|
if (value.equals("up")) {
|
|
region.mScrollValue =
|
|
TextTrackRegion.SCROLL_VALUE_SCROLL_UP;
|
|
} else {
|
|
log_warning("region setting", name, "has invalid value", value);
|
|
}
|
|
}
|
|
}
|
|
return region;
|
|
}
|
|
|
|
@Override
|
|
public void parse(String line) {
|
|
if (line.length() == 0) {
|
|
mPhase = mParseCueId;
|
|
} else if (line.contains("-->")) {
|
|
mPhase = mParseCueTime;
|
|
mPhase.parse(line);
|
|
} else {
|
|
int colonAt = line.indexOf(':');
|
|
if (colonAt <= 0 || colonAt >= line.length() - 1) {
|
|
log_warning("meta data header has invalid format", line);
|
|
}
|
|
String name = line.substring(0, colonAt);
|
|
String value = line.substring(colonAt + 1);
|
|
|
|
if (name.equals("Region")) {
|
|
TextTrackRegion region = parseRegion(value);
|
|
mListener.onRegionParsed(region);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
final private Phase mParseCueId = new Phase() {
|
|
@Override
|
|
public void parse(String line) {
|
|
if (line.length() == 0) {
|
|
return;
|
|
}
|
|
|
|
assert(mCue == null);
|
|
|
|
if (line.equals("NOTE") || line.startsWith("NOTE ")) {
|
|
mPhase = mParseCueText;
|
|
}
|
|
|
|
mCue = new TextTrackCue();
|
|
mCueTexts.clear();
|
|
|
|
mPhase = mParseCueTime;
|
|
if (line.contains("-->")) {
|
|
mPhase.parse(line);
|
|
} else {
|
|
mCue.mId = line;
|
|
}
|
|
}
|
|
};
|
|
|
|
final private Phase mParseCueTime = new Phase() {
|
|
@Override
|
|
public void parse(String line) {
|
|
int arrowAt = line.indexOf("-->");
|
|
if (arrowAt < 0) {
|
|
mCue = null;
|
|
mPhase = mParseCueId;
|
|
return;
|
|
}
|
|
|
|
String start = line.substring(0, arrowAt).trim();
|
|
// convert only initial and first other white-space to space
|
|
String rest = line.substring(arrowAt + 3)
|
|
.replaceFirst("^\\s+", "").replaceFirst("\\s+", " ");
|
|
int spaceAt = rest.indexOf(' ');
|
|
String end = spaceAt > 0 ? rest.substring(0, spaceAt) : rest;
|
|
rest = spaceAt > 0 ? rest.substring(spaceAt + 1) : "";
|
|
|
|
mCue.mStartTimeMs = parseTimestampMs(start);
|
|
mCue.mEndTimeMs = parseTimestampMs(end);
|
|
for (String setting: rest.split(" +")) {
|
|
int colonAt = setting.indexOf(':');
|
|
if (colonAt <= 0 || colonAt == setting.length() - 1) {
|
|
continue;
|
|
}
|
|
String name = setting.substring(0, colonAt);
|
|
String value = setting.substring(colonAt + 1);
|
|
|
|
if (name.equals("region")) {
|
|
mCue.mRegionId = value;
|
|
} else if (name.equals("vertical")) {
|
|
if (value.equals("rl")) {
|
|
mCue.mWritingDirection =
|
|
TextTrackCue.WRITING_DIRECTION_VERTICAL_RL;
|
|
} else if (value.equals("lr")) {
|
|
mCue.mWritingDirection =
|
|
TextTrackCue.WRITING_DIRECTION_VERTICAL_LR;
|
|
} else {
|
|
log_warning("cue setting", name, "has invalid value", value);
|
|
}
|
|
} else if (name.equals("line")) {
|
|
try {
|
|
/* TRICKY: we know that there are no spaces in value */
|
|
assert(value.indexOf(' ') < 0);
|
|
if (value.endsWith("%")) {
|
|
mCue.mSnapToLines = false;
|
|
mCue.mLinePosition = parseIntPercentage(value);
|
|
} else if (value.matches(".*[^0-9].*")) {
|
|
log_warning("cue setting", name,
|
|
"contains an invalid character", value);
|
|
} else {
|
|
mCue.mSnapToLines = true;
|
|
mCue.mLinePosition = Integer.parseInt(value);
|
|
}
|
|
} catch (NumberFormatException e) {
|
|
log_warning("cue setting", name,
|
|
"is not numeric or percentage", value);
|
|
}
|
|
// TODO: add support for optional alignment value [,start|middle|end]
|
|
} else if (name.equals("position")) {
|
|
try {
|
|
mCue.mTextPosition = parseIntPercentage(value);
|
|
} catch (NumberFormatException e) {
|
|
log_warning("cue setting", name,
|
|
"is not numeric or percentage", value);
|
|
}
|
|
} else if (name.equals("size")) {
|
|
try {
|
|
mCue.mSize = parseIntPercentage(value);
|
|
} catch (NumberFormatException e) {
|
|
log_warning("cue setting", name,
|
|
"is not numeric or percentage", value);
|
|
}
|
|
} else if (name.equals("align")) {
|
|
if (value.equals("start")) {
|
|
mCue.mAlignment = TextTrackCue.ALIGNMENT_START;
|
|
} else if (value.equals("middle")) {
|
|
mCue.mAlignment = TextTrackCue.ALIGNMENT_MIDDLE;
|
|
} else if (value.equals("end")) {
|
|
mCue.mAlignment = TextTrackCue.ALIGNMENT_END;
|
|
} else if (value.equals("left")) {
|
|
mCue.mAlignment = TextTrackCue.ALIGNMENT_LEFT;
|
|
} else if (value.equals("right")) {
|
|
mCue.mAlignment = TextTrackCue.ALIGNMENT_RIGHT;
|
|
} else {
|
|
log_warning("cue setting", name, "has invalid value", value);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mCue.mLinePosition != null ||
|
|
mCue.mSize != 100 ||
|
|
(mCue.mWritingDirection !=
|
|
TextTrackCue.WRITING_DIRECTION_HORIZONTAL)) {
|
|
mCue.mRegionId = "";
|
|
}
|
|
|
|
mPhase = mParseCueText;
|
|
}
|
|
};
|
|
|
|
/* also used for notes */
|
|
final private Phase mParseCueText = new Phase() {
|
|
@Override
|
|
public void parse(String line) {
|
|
if (line.length() == 0) {
|
|
yieldCue();
|
|
mPhase = mParseCueId;
|
|
return;
|
|
} else if (mCue != null) {
|
|
mCueTexts.add(line);
|
|
}
|
|
}
|
|
};
|
|
|
|
private void log_warning(
|
|
String nameType, String name, String message,
|
|
String subMessage, String value) {
|
|
Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
|
|
message + " ('" + value + "' " + subMessage + ")");
|
|
}
|
|
|
|
private void log_warning(
|
|
String nameType, String name, String message, String value) {
|
|
Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
|
|
message + " ('" + value + "')");
|
|
}
|
|
|
|
private void log_warning(String message, String value) {
|
|
Log.w(this.getClass().getName(), message + " ('" + value + "')");
|
|
}
|
|
}
|
|
|
|
/** @hide */
|
|
interface WebVttCueListener {
|
|
void onCueParsed(TextTrackCue cue);
|
|
void onRegionParsed(TextTrackRegion region);
|
|
}
|
|
|
|
/** @hide */
|
|
class WebVttTrack extends SubtitleTrack implements WebVttCueListener {
|
|
private static final String TAG = "WebVttTrack";
|
|
|
|
private final WebVttParser mParser = new WebVttParser(this);
|
|
private final UnstyledTextExtractor mExtractor =
|
|
new UnstyledTextExtractor();
|
|
private final Tokenizer mTokenizer = new Tokenizer(mExtractor);
|
|
private final Vector<Long> mTimestamps = new Vector<Long>();
|
|
private final WebVttRenderingWidget mRenderingWidget;
|
|
|
|
private final Map<String, TextTrackRegion> mRegions =
|
|
new HashMap<String, TextTrackRegion>();
|
|
private Long mCurrentRunID;
|
|
|
|
WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format) {
|
|
super(format);
|
|
|
|
mRenderingWidget = renderingWidget;
|
|
}
|
|
|
|
@Override
|
|
public WebVttRenderingWidget getRenderingWidget() {
|
|
return mRenderingWidget;
|
|
}
|
|
|
|
@Override
|
|
public void onData(byte[] data, boolean eos, long runID) {
|
|
try {
|
|
String str = new String(data, "UTF-8");
|
|
|
|
// implement intermixing restriction for WebVTT only for now
|
|
synchronized(mParser) {
|
|
if (mCurrentRunID != null && runID != mCurrentRunID) {
|
|
throw new IllegalStateException(
|
|
"Run #" + mCurrentRunID +
|
|
" in progress. Cannot process run #" + runID);
|
|
}
|
|
mCurrentRunID = runID;
|
|
mParser.parse(str);
|
|
if (eos) {
|
|
finishedRun(runID);
|
|
mParser.eos();
|
|
mRegions.clear();
|
|
mCurrentRunID = null;
|
|
}
|
|
}
|
|
} catch (java.io.UnsupportedEncodingException e) {
|
|
Log.w(TAG, "subtitle data is not UTF-8 encoded: " + e);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onCueParsed(TextTrackCue cue) {
|
|
synchronized (mParser) {
|
|
// resolve region
|
|
if (cue.mRegionId.length() != 0) {
|
|
cue.mRegion = mRegions.get(cue.mRegionId);
|
|
}
|
|
|
|
if (DEBUG) Log.v(TAG, "adding cue " + cue);
|
|
|
|
// tokenize text track string-lines into lines of spans
|
|
mTokenizer.reset();
|
|
for (String s: cue.mStrings) {
|
|
mTokenizer.tokenize(s);
|
|
}
|
|
cue.mLines = mExtractor.getText();
|
|
if (DEBUG) Log.v(TAG, cue.appendLinesToBuilder(
|
|
cue.appendStringsToBuilder(
|
|
new StringBuilder()).append(" simplified to: "))
|
|
.toString());
|
|
|
|
// extract inner timestamps
|
|
for (TextTrackCueSpan[] line: cue.mLines) {
|
|
for (TextTrackCueSpan span: line) {
|
|
if (span.mTimestampMs > cue.mStartTimeMs &&
|
|
span.mTimestampMs < cue.mEndTimeMs &&
|
|
!mTimestamps.contains(span.mTimestampMs)) {
|
|
mTimestamps.add(span.mTimestampMs);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (mTimestamps.size() > 0) {
|
|
cue.mInnerTimesMs = new long[mTimestamps.size()];
|
|
for (int ix=0; ix < mTimestamps.size(); ++ix) {
|
|
cue.mInnerTimesMs[ix] = mTimestamps.get(ix);
|
|
}
|
|
mTimestamps.clear();
|
|
} else {
|
|
cue.mInnerTimesMs = null;
|
|
}
|
|
|
|
cue.mRunID = mCurrentRunID;
|
|
}
|
|
|
|
addCue(cue);
|
|
}
|
|
|
|
@Override
|
|
public void onRegionParsed(TextTrackRegion region) {
|
|
synchronized(mParser) {
|
|
mRegions.put(region.mId, region);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void updateView(Vector<SubtitleTrack.Cue> activeCues) {
|
|
if (!mVisible) {
|
|
// don't keep the state if we are not visible
|
|
return;
|
|
}
|
|
|
|
if (DEBUG && mTimeProvider != null) {
|
|
try {
|
|
Log.d(TAG, "at " +
|
|
(mTimeProvider.getCurrentTimeUs(false, true) / 1000) +
|
|
" ms the active cues are:");
|
|
} catch (IllegalStateException e) {
|
|
Log.d(TAG, "at (illegal state) the active cues are:");
|
|
}
|
|
}
|
|
|
|
if (mRenderingWidget != null) {
|
|
mRenderingWidget.setActiveCues(activeCues);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Widget capable of rendering WebVTT captions.
|
|
*
|
|
* @hide
|
|
*/
|
|
class WebVttRenderingWidget extends ViewGroup implements SubtitleTrack.RenderingWidget {
|
|
private static final boolean DEBUG = false;
|
|
|
|
private static final CaptionStyle DEFAULT_CAPTION_STYLE = CaptionStyle.DEFAULT;
|
|
|
|
private static final int DEBUG_REGION_BACKGROUND = 0x800000FF;
|
|
private static final int DEBUG_CUE_BACKGROUND = 0x80FF0000;
|
|
|
|
/** WebVtt specifies line height as 5.3% of the viewport height. */
|
|
private static final float LINE_HEIGHT_RATIO = 0.0533f;
|
|
|
|
/** Map of active regions, used to determine enter/exit. */
|
|
private final ArrayMap<TextTrackRegion, RegionLayout> mRegionBoxes =
|
|
new ArrayMap<TextTrackRegion, RegionLayout>();
|
|
|
|
/** Map of active cues, used to determine enter/exit. */
|
|
private final ArrayMap<TextTrackCue, CueLayout> mCueBoxes =
|
|
new ArrayMap<TextTrackCue, CueLayout>();
|
|
|
|
/** Captioning manager, used to obtain and track caption properties. */
|
|
private final CaptioningManager mManager;
|
|
|
|
/** Callback for rendering changes. */
|
|
private OnChangedListener mListener;
|
|
|
|
/** Current caption style. */
|
|
private CaptionStyle mCaptionStyle;
|
|
|
|
/** Current font size, computed from font scaling factor and height. */
|
|
private float mFontSize;
|
|
|
|
/** Whether a caption style change listener is registered. */
|
|
private boolean mHasChangeListener;
|
|
|
|
public WebVttRenderingWidget(Context context) {
|
|
this(context, null);
|
|
}
|
|
|
|
public WebVttRenderingWidget(Context context, AttributeSet attrs) {
|
|
this(context, attrs, 0);
|
|
}
|
|
|
|
public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr) {
|
|
this(context, attrs, defStyleAttr, 0);
|
|
}
|
|
|
|
public WebVttRenderingWidget(
|
|
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
|
|
super(context, attrs, defStyleAttr, defStyleRes);
|
|
|
|
// Cannot render text over video when layer type is hardware.
|
|
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
|
|
|
|
mManager = (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
|
|
mCaptionStyle = mManager.getUserStyle();
|
|
mFontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO;
|
|
}
|
|
|
|
@Override
|
|
public void setSize(int width, int height) {
|
|
final int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
|
|
final int heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);
|
|
|
|
measure(widthSpec, heightSpec);
|
|
layout(0, 0, width, height);
|
|
}
|
|
|
|
@Override
|
|
public void onAttachedToWindow() {
|
|
super.onAttachedToWindow();
|
|
|
|
manageChangeListener();
|
|
}
|
|
|
|
@Override
|
|
public void onDetachedFromWindow() {
|
|
super.onDetachedFromWindow();
|
|
|
|
manageChangeListener();
|
|
}
|
|
|
|
@Override
|
|
public void setOnChangedListener(OnChangedListener listener) {
|
|
mListener = listener;
|
|
}
|
|
|
|
@Override
|
|
public void setVisible(boolean visible) {
|
|
if (visible) {
|
|
setVisibility(View.VISIBLE);
|
|
} else {
|
|
setVisibility(View.GONE);
|
|
}
|
|
|
|
manageChangeListener();
|
|
}
|
|
|
|
/**
|
|
* Manages whether this renderer is listening for caption style changes.
|
|
*/
|
|
private void manageChangeListener() {
|
|
final boolean needsListener = isAttachedToWindow() && getVisibility() == View.VISIBLE;
|
|
if (mHasChangeListener != needsListener) {
|
|
mHasChangeListener = needsListener;
|
|
|
|
if (needsListener) {
|
|
mManager.addCaptioningChangeListener(mCaptioningListener);
|
|
|
|
final CaptionStyle captionStyle = mManager.getUserStyle();
|
|
final float fontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO;
|
|
setCaptionStyle(captionStyle, fontSize);
|
|
} else {
|
|
mManager.removeCaptioningChangeListener(mCaptioningListener);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void setActiveCues(Vector<SubtitleTrack.Cue> activeCues) {
|
|
final Context context = getContext();
|
|
final CaptionStyle captionStyle = mCaptionStyle;
|
|
final float fontSize = mFontSize;
|
|
|
|
prepForPrune();
|
|
|
|
// Ensure we have all necessary cue and region boxes.
|
|
final int count = activeCues.size();
|
|
for (int i = 0; i < count; i++) {
|
|
final TextTrackCue cue = (TextTrackCue) activeCues.get(i);
|
|
final TextTrackRegion region = cue.mRegion;
|
|
if (region != null) {
|
|
RegionLayout regionBox = mRegionBoxes.get(region);
|
|
if (regionBox == null) {
|
|
regionBox = new RegionLayout(context, region, captionStyle, fontSize);
|
|
mRegionBoxes.put(region, regionBox);
|
|
addView(regionBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
|
|
}
|
|
regionBox.put(cue);
|
|
} else {
|
|
CueLayout cueBox = mCueBoxes.get(cue);
|
|
if (cueBox == null) {
|
|
cueBox = new CueLayout(context, cue, captionStyle, fontSize);
|
|
mCueBoxes.put(cue, cueBox);
|
|
addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
|
|
}
|
|
cueBox.update();
|
|
cueBox.setOrder(i);
|
|
}
|
|
}
|
|
|
|
prune();
|
|
|
|
// Force measurement and layout.
|
|
final int width = getWidth();
|
|
final int height = getHeight();
|
|
setSize(width, height);
|
|
|
|
if (mListener != null) {
|
|
mListener.onChanged(this);
|
|
}
|
|
}
|
|
|
|
private void setCaptionStyle(CaptionStyle captionStyle, float fontSize) {
|
|
captionStyle = DEFAULT_CAPTION_STYLE.applyStyle(captionStyle);
|
|
mCaptionStyle = captionStyle;
|
|
mFontSize = fontSize;
|
|
|
|
final int cueCount = mCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mCueBoxes.valueAt(i);
|
|
cueBox.setCaptionStyle(captionStyle, fontSize);
|
|
}
|
|
|
|
final int regionCount = mRegionBoxes.size();
|
|
for (int i = 0; i < regionCount; i++) {
|
|
final RegionLayout regionBox = mRegionBoxes.valueAt(i);
|
|
regionBox.setCaptionStyle(captionStyle, fontSize);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove inactive cues and regions.
|
|
*/
|
|
private void prune() {
|
|
int regionCount = mRegionBoxes.size();
|
|
for (int i = 0; i < regionCount; i++) {
|
|
final RegionLayout regionBox = mRegionBoxes.valueAt(i);
|
|
if (regionBox.prune()) {
|
|
removeView(regionBox);
|
|
mRegionBoxes.removeAt(i);
|
|
regionCount--;
|
|
i--;
|
|
}
|
|
}
|
|
|
|
int cueCount = mCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mCueBoxes.valueAt(i);
|
|
if (!cueBox.isActive()) {
|
|
removeView(cueBox);
|
|
mCueBoxes.removeAt(i);
|
|
cueCount--;
|
|
i--;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset active cues and regions.
|
|
*/
|
|
private void prepForPrune() {
|
|
final int regionCount = mRegionBoxes.size();
|
|
for (int i = 0; i < regionCount; i++) {
|
|
final RegionLayout regionBox = mRegionBoxes.valueAt(i);
|
|
regionBox.prepForPrune();
|
|
}
|
|
|
|
final int cueCount = mCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mCueBoxes.valueAt(i);
|
|
cueBox.prepForPrune();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
|
|
final int regionCount = mRegionBoxes.size();
|
|
for (int i = 0; i < regionCount; i++) {
|
|
final RegionLayout regionBox = mRegionBoxes.valueAt(i);
|
|
regionBox.measureForParent(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
|
|
final int cueCount = mCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mCueBoxes.valueAt(i);
|
|
cueBox.measureForParent(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onLayout(boolean changed, int l, int t, int r, int b) {
|
|
final int viewportWidth = r - l;
|
|
final int viewportHeight = b - t;
|
|
|
|
setCaptionStyle(mCaptionStyle,
|
|
mManager.getFontScale() * LINE_HEIGHT_RATIO * viewportHeight);
|
|
|
|
final int regionCount = mRegionBoxes.size();
|
|
for (int i = 0; i < regionCount; i++) {
|
|
final RegionLayout regionBox = mRegionBoxes.valueAt(i);
|
|
layoutRegion(viewportWidth, viewportHeight, regionBox);
|
|
}
|
|
|
|
final int cueCount = mCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mCueBoxes.valueAt(i);
|
|
layoutCue(viewportWidth, viewportHeight, cueBox);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lays out a region within the viewport. The region handles layout for
|
|
* contained cues.
|
|
*/
|
|
private void layoutRegion(
|
|
int viewportWidth, int viewportHeight,
|
|
RegionLayout regionBox) {
|
|
final TextTrackRegion region = regionBox.getRegion();
|
|
final int regionHeight = regionBox.getMeasuredHeight();
|
|
final int regionWidth = regionBox.getMeasuredWidth();
|
|
|
|
// TODO: Account for region anchor point.
|
|
final float x = region.mViewportAnchorPointX;
|
|
final float y = region.mViewportAnchorPointY;
|
|
final int left = (int) (x * (viewportWidth - regionWidth) / 100);
|
|
final int top = (int) (y * (viewportHeight - regionHeight) / 100);
|
|
|
|
regionBox.layout(left, top, left + regionWidth, top + regionHeight);
|
|
}
|
|
|
|
/**
|
|
* Lays out a cue within the viewport.
|
|
*/
|
|
private void layoutCue(
|
|
int viewportWidth, int viewportHeight, CueLayout cueBox) {
|
|
final TextTrackCue cue = cueBox.getCue();
|
|
final int direction = getLayoutDirection();
|
|
final int absAlignment = resolveCueAlignment(direction, cue.mAlignment);
|
|
final boolean cueSnapToLines = cue.mSnapToLines;
|
|
|
|
int size = 100 * cueBox.getMeasuredWidth() / viewportWidth;
|
|
|
|
// Determine raw x-position.
|
|
int xPosition;
|
|
switch (absAlignment) {
|
|
case TextTrackCue.ALIGNMENT_LEFT:
|
|
xPosition = cue.mTextPosition;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_RIGHT:
|
|
xPosition = cue.mTextPosition - size;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_MIDDLE:
|
|
default:
|
|
xPosition = cue.mTextPosition - size / 2;
|
|
break;
|
|
}
|
|
|
|
// Adjust x-position for layout.
|
|
if (direction == LAYOUT_DIRECTION_RTL) {
|
|
xPosition = 100 - xPosition;
|
|
}
|
|
|
|
// If the text track cue snap-to-lines flag is set, adjust
|
|
// x-position and size for padding. This is equivalent to placing the
|
|
// cue within the title-safe area.
|
|
if (cueSnapToLines) {
|
|
final int paddingLeft = 100 * getPaddingLeft() / viewportWidth;
|
|
final int paddingRight = 100 * getPaddingRight() / viewportWidth;
|
|
if (xPosition < paddingLeft && xPosition + size > paddingLeft) {
|
|
xPosition += paddingLeft;
|
|
size -= paddingLeft;
|
|
}
|
|
final float rightEdge = 100 - paddingRight;
|
|
if (xPosition < rightEdge && xPosition + size > rightEdge) {
|
|
size -= paddingRight;
|
|
}
|
|
}
|
|
|
|
// Compute absolute left position and width.
|
|
final int left = xPosition * viewportWidth / 100;
|
|
final int width = size * viewportWidth / 100;
|
|
|
|
// Determine initial y-position.
|
|
final int yPosition = calculateLinePosition(cueBox);
|
|
|
|
// Compute absolute final top position and height.
|
|
final int height = cueBox.getMeasuredHeight();
|
|
final int top;
|
|
if (yPosition < 0) {
|
|
// TODO: This needs to use the actual height of prior boxes.
|
|
top = viewportHeight + yPosition * height;
|
|
} else {
|
|
top = yPosition * (viewportHeight - height) / 100;
|
|
}
|
|
|
|
// Layout cue in final position.
|
|
cueBox.layout(left, top, left + width, top + height);
|
|
}
|
|
|
|
/**
|
|
* Calculates the line position for a cue.
|
|
* <p>
|
|
* If the resulting position is negative, it represents a bottom-aligned
|
|
* position relative to the number of active cues. Otherwise, it represents
|
|
* a percentage [0-100] of the viewport height.
|
|
*/
|
|
private int calculateLinePosition(CueLayout cueBox) {
|
|
final TextTrackCue cue = cueBox.getCue();
|
|
final Integer linePosition = cue.mLinePosition;
|
|
final boolean snapToLines = cue.mSnapToLines;
|
|
final boolean autoPosition = (linePosition == null);
|
|
|
|
if (!snapToLines && !autoPosition && (linePosition < 0 || linePosition > 100)) {
|
|
// Invalid line position defaults to 100.
|
|
return 100;
|
|
} else if (!autoPosition) {
|
|
// Use the valid, supplied line position.
|
|
return linePosition;
|
|
} else if (!snapToLines) {
|
|
// Automatic, non-snapped line position defaults to 100.
|
|
return 100;
|
|
} else {
|
|
// Automatic snapped line position uses active cue order.
|
|
return -(cueBox.mOrder + 1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves cue alignment according to the specified layout direction.
|
|
*/
|
|
private static int resolveCueAlignment(int layoutDirection, int alignment) {
|
|
switch (alignment) {
|
|
case TextTrackCue.ALIGNMENT_START:
|
|
return layoutDirection == View.LAYOUT_DIRECTION_LTR ?
|
|
TextTrackCue.ALIGNMENT_LEFT : TextTrackCue.ALIGNMENT_RIGHT;
|
|
case TextTrackCue.ALIGNMENT_END:
|
|
return layoutDirection == View.LAYOUT_DIRECTION_LTR ?
|
|
TextTrackCue.ALIGNMENT_RIGHT : TextTrackCue.ALIGNMENT_LEFT;
|
|
}
|
|
return alignment;
|
|
}
|
|
|
|
private final CaptioningChangeListener mCaptioningListener = new CaptioningChangeListener() {
|
|
@Override
|
|
public void onFontScaleChanged(float fontScale) {
|
|
final float fontSize = fontScale * getHeight() * LINE_HEIGHT_RATIO;
|
|
setCaptionStyle(mCaptionStyle, fontSize);
|
|
}
|
|
|
|
@Override
|
|
public void onUserStyleChanged(CaptionStyle userStyle) {
|
|
setCaptionStyle(userStyle, mFontSize);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* A text track region represents a portion of the video viewport and
|
|
* provides a rendering area for text track cues.
|
|
*/
|
|
private static class RegionLayout extends LinearLayout {
|
|
private final ArrayList<CueLayout> mRegionCueBoxes = new ArrayList<CueLayout>();
|
|
private final TextTrackRegion mRegion;
|
|
|
|
private CaptionStyle mCaptionStyle;
|
|
private float mFontSize;
|
|
|
|
public RegionLayout(Context context, TextTrackRegion region, CaptionStyle captionStyle,
|
|
float fontSize) {
|
|
super(context);
|
|
|
|
mRegion = region;
|
|
mCaptionStyle = captionStyle;
|
|
mFontSize = fontSize;
|
|
|
|
// TODO: Add support for vertical text
|
|
setOrientation(VERTICAL);
|
|
|
|
if (DEBUG) {
|
|
setBackgroundColor(DEBUG_REGION_BACKGROUND);
|
|
} else {
|
|
setBackgroundColor(captionStyle.windowColor);
|
|
}
|
|
}
|
|
|
|
public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) {
|
|
mCaptionStyle = captionStyle;
|
|
mFontSize = fontSize;
|
|
|
|
final int cueCount = mRegionCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mRegionCueBoxes.get(i);
|
|
cueBox.setCaptionStyle(captionStyle, fontSize);
|
|
}
|
|
|
|
setBackgroundColor(captionStyle.windowColor);
|
|
}
|
|
|
|
/**
|
|
* Performs the parent's measurement responsibilities, then
|
|
* automatically performs its own measurement.
|
|
*/
|
|
public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) {
|
|
final TextTrackRegion region = mRegion;
|
|
final int specWidth = MeasureSpec.getSize(widthMeasureSpec);
|
|
final int specHeight = MeasureSpec.getSize(heightMeasureSpec);
|
|
final int width = (int) region.mWidth;
|
|
|
|
// Determine the absolute maximum region size as the requested size.
|
|
final int size = width * specWidth / 100;
|
|
|
|
widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST);
|
|
heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST);
|
|
measure(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
|
|
/**
|
|
* Prepares this region for pruning by setting all tracks as inactive.
|
|
* <p>
|
|
* Tracks that are added or updated using {@link #put(TextTrackCue)}
|
|
* after this calling this method will be marked as active.
|
|
*/
|
|
public void prepForPrune() {
|
|
final int cueCount = mRegionCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mRegionCueBoxes.get(i);
|
|
cueBox.prepForPrune();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Adds a {@link TextTrackCue} to this region. If the track had already
|
|
* been added, updates its active state.
|
|
*
|
|
* @param cue
|
|
*/
|
|
public void put(TextTrackCue cue) {
|
|
final int cueCount = mRegionCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mRegionCueBoxes.get(i);
|
|
if (cueBox.getCue() == cue) {
|
|
cueBox.update();
|
|
return;
|
|
}
|
|
}
|
|
|
|
final CueLayout cueBox = new CueLayout(getContext(), cue, mCaptionStyle, mFontSize);
|
|
mRegionCueBoxes.add(cueBox);
|
|
addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
|
|
|
|
if (getChildCount() > mRegion.mLines) {
|
|
removeViewAt(0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove all inactive tracks from this region.
|
|
*
|
|
* @return true if this region is empty and should be pruned
|
|
*/
|
|
public boolean prune() {
|
|
int cueCount = mRegionCueBoxes.size();
|
|
for (int i = 0; i < cueCount; i++) {
|
|
final CueLayout cueBox = mRegionCueBoxes.get(i);
|
|
if (!cueBox.isActive()) {
|
|
mRegionCueBoxes.remove(i);
|
|
removeView(cueBox);
|
|
cueCount--;
|
|
i--;
|
|
}
|
|
}
|
|
|
|
return mRegionCueBoxes.isEmpty();
|
|
}
|
|
|
|
/**
|
|
* @return the region data backing this layout
|
|
*/
|
|
public TextTrackRegion getRegion() {
|
|
return mRegion;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A text track cue is the unit of time-sensitive data in a text track,
|
|
* corresponding for instance for subtitles and captions to the text that
|
|
* appears at a particular time and disappears at another time.
|
|
* <p>
|
|
* A single cue may contain multiple {@link SpanLayout}s, each representing a
|
|
* single line of text.
|
|
*/
|
|
private static class CueLayout extends LinearLayout {
|
|
public final TextTrackCue mCue;
|
|
|
|
private CaptionStyle mCaptionStyle;
|
|
private float mFontSize;
|
|
|
|
private boolean mActive;
|
|
private int mOrder;
|
|
|
|
public CueLayout(
|
|
Context context, TextTrackCue cue, CaptionStyle captionStyle, float fontSize) {
|
|
super(context);
|
|
|
|
mCue = cue;
|
|
mCaptionStyle = captionStyle;
|
|
mFontSize = fontSize;
|
|
|
|
// TODO: Add support for vertical text.
|
|
final boolean horizontal = cue.mWritingDirection
|
|
== TextTrackCue.WRITING_DIRECTION_HORIZONTAL;
|
|
setOrientation(horizontal ? VERTICAL : HORIZONTAL);
|
|
|
|
switch (cue.mAlignment) {
|
|
case TextTrackCue.ALIGNMENT_END:
|
|
setGravity(Gravity.END);
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_LEFT:
|
|
setGravity(Gravity.LEFT);
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_MIDDLE:
|
|
setGravity(horizontal
|
|
? Gravity.CENTER_HORIZONTAL : Gravity.CENTER_VERTICAL);
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_RIGHT:
|
|
setGravity(Gravity.RIGHT);
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_START:
|
|
setGravity(Gravity.START);
|
|
break;
|
|
}
|
|
|
|
if (DEBUG) {
|
|
setBackgroundColor(DEBUG_CUE_BACKGROUND);
|
|
}
|
|
|
|
update();
|
|
}
|
|
|
|
public void setCaptionStyle(CaptionStyle style, float fontSize) {
|
|
mCaptionStyle = style;
|
|
mFontSize = fontSize;
|
|
|
|
final int n = getChildCount();
|
|
for (int i = 0; i < n; i++) {
|
|
final View child = getChildAt(i);
|
|
if (child instanceof SpanLayout) {
|
|
((SpanLayout) child).setCaptionStyle(style, fontSize);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void prepForPrune() {
|
|
mActive = false;
|
|
}
|
|
|
|
public void update() {
|
|
mActive = true;
|
|
|
|
removeAllViews();
|
|
|
|
final int cueAlignment = resolveCueAlignment(getLayoutDirection(), mCue.mAlignment);
|
|
final Alignment alignment;
|
|
switch (cueAlignment) {
|
|
case TextTrackCue.ALIGNMENT_LEFT:
|
|
alignment = Alignment.ALIGN_LEFT;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_RIGHT:
|
|
alignment = Alignment.ALIGN_RIGHT;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_MIDDLE:
|
|
default:
|
|
alignment = Alignment.ALIGN_CENTER;
|
|
}
|
|
|
|
final CaptionStyle captionStyle = mCaptionStyle;
|
|
final float fontSize = mFontSize;
|
|
final TextTrackCueSpan[][] lines = mCue.mLines;
|
|
final int lineCount = lines.length;
|
|
for (int i = 0; i < lineCount; i++) {
|
|
final SpanLayout lineBox = new SpanLayout(getContext(), lines[i]);
|
|
lineBox.setAlignment(alignment);
|
|
lineBox.setCaptionStyle(captionStyle, fontSize);
|
|
|
|
addView(lineBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
|
|
/**
|
|
* Performs the parent's measurement responsibilities, then
|
|
* automatically performs its own measurement.
|
|
*/
|
|
public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) {
|
|
final TextTrackCue cue = mCue;
|
|
final int specWidth = MeasureSpec.getSize(widthMeasureSpec);
|
|
final int specHeight = MeasureSpec.getSize(heightMeasureSpec);
|
|
final int direction = getLayoutDirection();
|
|
final int absAlignment = resolveCueAlignment(direction, cue.mAlignment);
|
|
|
|
// Determine the maximum size of cue based on its starting position
|
|
// and the direction in which it grows.
|
|
final int maximumSize;
|
|
switch (absAlignment) {
|
|
case TextTrackCue.ALIGNMENT_LEFT:
|
|
maximumSize = 100 - cue.mTextPosition;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_RIGHT:
|
|
maximumSize = cue.mTextPosition;
|
|
break;
|
|
case TextTrackCue.ALIGNMENT_MIDDLE:
|
|
if (cue.mTextPosition <= 50) {
|
|
maximumSize = cue.mTextPosition * 2;
|
|
} else {
|
|
maximumSize = (100 - cue.mTextPosition) * 2;
|
|
}
|
|
break;
|
|
default:
|
|
maximumSize = 0;
|
|
}
|
|
|
|
// Determine absolute maximum cue size as the smaller of the
|
|
// requested size and the maximum theoretical size.
|
|
final int size = Math.min(cue.mSize, maximumSize) * specWidth / 100;
|
|
widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST);
|
|
heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST);
|
|
measure(widthMeasureSpec, heightMeasureSpec);
|
|
}
|
|
|
|
/**
|
|
* Sets the order of this cue in the list of active cues.
|
|
*
|
|
* @param order the order of this cue in the list of active cues
|
|
*/
|
|
public void setOrder(int order) {
|
|
mOrder = order;
|
|
}
|
|
|
|
/**
|
|
* @return whether this cue is marked as active
|
|
*/
|
|
public boolean isActive() {
|
|
return mActive;
|
|
}
|
|
|
|
/**
|
|
* @return the cue data backing this layout
|
|
*/
|
|
public TextTrackCue getCue() {
|
|
return mCue;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A text track line represents a single line of text within a cue.
|
|
* <p>
|
|
* A single line may contain multiple spans, each representing a section of
|
|
* text that may be enabled or disabled at a particular time.
|
|
*/
|
|
private static class SpanLayout extends SubtitleView {
|
|
private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
|
|
private final TextTrackCueSpan[] mSpans;
|
|
|
|
public SpanLayout(Context context, TextTrackCueSpan[] spans) {
|
|
super(context);
|
|
|
|
mSpans = spans;
|
|
|
|
update();
|
|
}
|
|
|
|
public void update() {
|
|
final SpannableStringBuilder builder = mBuilder;
|
|
final TextTrackCueSpan[] spans = mSpans;
|
|
|
|
builder.clear();
|
|
builder.clearSpans();
|
|
|
|
final int spanCount = spans.length;
|
|
for (int i = 0; i < spanCount; i++) {
|
|
final TextTrackCueSpan span = spans[i];
|
|
if (span.mEnabled) {
|
|
builder.append(spans[i].mText);
|
|
}
|
|
}
|
|
|
|
setText(builder);
|
|
}
|
|
|
|
public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) {
|
|
setBackgroundColor(captionStyle.backgroundColor);
|
|
setForegroundColor(captionStyle.foregroundColor);
|
|
setEdgeColor(captionStyle.edgeColor);
|
|
setEdgeType(captionStyle.edgeType);
|
|
setTypeface(captionStyle.getTypeface());
|
|
setTextSize(fontSize);
|
|
}
|
|
}
|
|
}
|