/* * 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 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; } } } } /** * Supporting July 10 2013 draft version * * @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.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 mTimestamps = new Vector(); private final WebVttRenderingWidget mRenderingWidget; private final Map mRegions = new HashMap(); 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 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 mRegionBoxes = new ArrayMap(); /** Map of active cues, used to determine enter/exit. */ private final ArrayMap mCueBoxes = new ArrayMap(); /** 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 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. *

* 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 mRegionCueBoxes = new ArrayList(); 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. *

* 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. *

* 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. *

* 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); } } }