From 76935a5890ed8dd9d47f76f7593cc1c14c3844db Mon Sep 17 00:00:00 2001 From: Lajos Molnar Date: Thu, 15 Aug 2013 16:43:08 -0700 Subject: [PATCH] Internal WebVtt subtitle support Change-Id: Ia55673a68a99c277dd6e3246e6f1913c8eb476d8 Signed-off-by: Lajos Molnar Bug: 10326117 --- media/java/android/media/WebVttRenderer.java | 1094 ++++++++++++++++++++++++++ 1 file changed, 1094 insertions(+) create mode 100644 media/java/android/media/WebVttRenderer.java diff --git a/media/java/android/media/WebVttRenderer.java b/media/java/android/media/WebVttRenderer.java new file mode 100644 index 000000000000..527c57f6401c --- /dev/null +++ b/media/java/android/media/WebVttRenderer.java @@ -0,0 +1,1094 @@ +package android.media; + +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup.LayoutParams; +import android.widget.TextView; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Vector; + +/** @hide */ +public class WebVttRenderer extends SubtitleController.Renderer { + private TextView mMyTextView; + + public WebVttRenderer(Context context, AttributeSet attrs) { + mMyTextView = new WebVttView(context, attrs); + } + + @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) { + return new WebVttTrack(format, mMyTextView); + } +} + +/** @hide */ +class WebVttView extends TextView { + public WebVttView(Context context, AttributeSet attrs) { + super(context, attrs); + setTextColor(0xffffff00); + setTextSize(46); + setTextAlignment(TextView.TEXT_ALIGNMENT_CENTER); + setLayoutParams(new LayoutParams( + LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); + } +} + +/** @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 mLines = new Vector(); + Vector mCurrentLine = new Vector(); + 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 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 == cue.mLinePosition) && + 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; + } + } + } +} + +/** @hide */ +class WebVttParser { + private static final String TAG = "WebVttParser"; + private Phase mPhase; + private TextTrackCue mCue; + private Vector 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(); + } + + /* 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.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")) { + try { + int lines = Integer.parseInt(value); + if (lines >= 0) { + region.mLines = lines; + } else { + log_warning("region setting", name, "is negative", value); + } + } 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 { + int linePosition; + /* TRICKY: we know that there are no spaces in value */ + assert(value.indexOf(' ') < 0); + if (value.endsWith("%")) { + linePosition = Integer.parseInt( + value.substring(0, value.length() - 1)); + if (linePosition < 0 || linePosition > 100) { + log_warning("cue setting", name, "is out of range", value); + continue; + } + mCue.mSnapToLines = false; + mCue.mLinePosition = linePosition; + } else { + mCue.mSnapToLines = true; + mCue.mLinePosition = Integer.parseInt(value); + } + } catch (NumberFormatException e) { + log_warning("cue setting", name, + "is not numeric or percentage", value); + } + } 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 TextView mTextView; + + private final WebVttParser mParser = new WebVttParser(this); + private final UnstyledTextExtractor mExtractor = + new UnstyledTextExtractor(); + private final Tokenizer mTokenizer = new Tokenizer(mExtractor); + private final Vector mTimestamps = new Vector(); + + private final Map mRegions = + new HashMap(); + private Long mCurrentRunID; + + WebVttTrack(MediaFormat format, TextView textView) { + super(format); + mTextView = textView; + } + + @Override + public View getView() { + return mTextView; + } + + @Override + public void onData(String data, boolean eos, long runID) { + // 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(data); + if (eos) { + finishedRun(runID); + mParser.eos(); + mRegions.clear(); + mCurrentRunID = null; + } + } + } + + @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); + } + } + + public void updateView(Vector 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:"); + } + } + StringBuilder text = new StringBuilder(); + StringBuilder lineBuilder = new StringBuilder(); + for (Cue o: activeCues) { + TextTrackCue cue = (TextTrackCue)o; + if (DEBUG) Log.d(TAG, cue.toString()); + for (TextTrackCueSpan[] line: cue.mLines) { + for (TextTrackCueSpan span: line) { + if (!span.mEnabled) { + continue; + } + lineBuilder.append(span.mText); + } + if (lineBuilder.length() > 0) { + text.append(lineBuilder.toString()).append("\n"); + lineBuilder.delete(0, lineBuilder.length()); + } + } + } + + if (mTextView != null) { + if (DEBUG) Log.d(TAG, "updating to " + text.toString()); + mTextView.setText(text.toString()); + mTextView.postInvalidate(); + } + } +} -- 2.11.0