/* * Copyright (C) 2014 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.content.res.Resources; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.Typeface; import android.text.Spannable; import android.text.SpannableStringBuilder; import android.text.TextPaint; import android.text.style.CharacterStyle; import android.text.style.StyleSpan; import android.text.style.UnderlineSpan; import android.text.style.UpdateAppearance; import android.util.AttributeSet; import android.util.Log; import android.util.TypedValue; 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 android.widget.TextView; import java.util.ArrayList; import java.util.Arrays; import java.util.Vector; /** @hide */ public class ClosedCaptionRenderer extends SubtitleController.Renderer { private final Context mContext; private Cea608CCWidget mCCWidget; public ClosedCaptionRenderer(Context context) { mContext = context; } @Override public boolean supports(MediaFormat format) { if (format.containsKey(MediaFormat.KEY_MIME)) { String mimeType = format.getString(MediaFormat.KEY_MIME); return MediaPlayer.MEDIA_MIMETYPE_TEXT_CEA_608.equals(mimeType); } return false; } @Override public SubtitleTrack createTrack(MediaFormat format) { String mimeType = format.getString(MediaFormat.KEY_MIME); if (MediaPlayer.MEDIA_MIMETYPE_TEXT_CEA_608.equals(mimeType)) { if (mCCWidget == null) { mCCWidget = new Cea608CCWidget(mContext); } return new Cea608CaptionTrack(mCCWidget, format); } throw new RuntimeException("No matching format: " + format.toString()); } } /** @hide */ class Cea608CaptionTrack extends SubtitleTrack { private final Cea608CCParser mCCParser; private final Cea608CCWidget mRenderingWidget; Cea608CaptionTrack(Cea608CCWidget renderingWidget, MediaFormat format) { super(format); mRenderingWidget = renderingWidget; mCCParser = new Cea608CCParser(mRenderingWidget); } @Override public void onData(byte[] data, boolean eos, long runID) { mCCParser.parse(data); } @Override public RenderingWidget getRenderingWidget() { return mRenderingWidget; } @Override public void updateView(Vector activeCues) { // Overriding with NO-OP, CC rendering by-passes this } } /** * Abstract widget class to render a closed caption track. * * @hide */ abstract class ClosedCaptionWidget extends ViewGroup implements SubtitleTrack.RenderingWidget { /** @hide */ interface ClosedCaptionLayout { void setCaptionStyle(CaptionStyle captionStyle); void setFontScale(float scale); } private static final CaptionStyle DEFAULT_CAPTION_STYLE = CaptionStyle.DEFAULT; /** Captioning manager, used to obtain and track caption properties. */ private final CaptioningManager mManager; /** Current caption style. */ protected CaptionStyle mCaptionStyle; /** Callback for rendering changes. */ protected OnChangedListener mListener; /** Concrete layout of CC. */ protected ClosedCaptionLayout mClosedCaptionLayout; /** Whether a caption style change listener is registered. */ private boolean mHasChangeListener; public ClosedCaptionWidget(Context context) { this(context, null); } public ClosedCaptionWidget(Context context, AttributeSet attrs) { this(context, attrs, 0); } public ClosedCaptionWidget(Context context, AttributeSet attrs, int defStyle) { this(context, attrs, defStyle, 0); } public ClosedCaptionWidget(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 = DEFAULT_CAPTION_STYLE.applyStyle(mManager.getUserStyle()); mClosedCaptionLayout = createCaptionLayout(context); mClosedCaptionLayout.setCaptionStyle(mCaptionStyle); mClosedCaptionLayout.setFontScale(mManager.getFontScale()); addView((ViewGroup) mClosedCaptionLayout, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); requestLayout(); } public abstract ClosedCaptionLayout createCaptionLayout(Context context); @Override public void setOnChangedListener(OnChangedListener listener) { mListener = listener; } @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 setVisible(boolean visible) { if (visible) { setVisibility(View.VISIBLE); } else { setVisibility(View.GONE); } manageChangeListener(); } @Override public void onAttachedToWindow() { super.onAttachedToWindow(); manageChangeListener(); } @Override public void onDetachedFromWindow() { super.onDetachedFromWindow(); manageChangeListener(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); ((ViewGroup) mClosedCaptionLayout).measure(widthMeasureSpec, heightMeasureSpec); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { ((ViewGroup) mClosedCaptionLayout).layout(l, t, r, b); } /** * Manages whether this renderer is listening for caption style changes. */ private final CaptioningChangeListener mCaptioningListener = new CaptioningChangeListener() { @Override public void onUserStyleChanged(CaptionStyle userStyle) { mCaptionStyle = DEFAULT_CAPTION_STYLE.applyStyle(userStyle); mClosedCaptionLayout.setCaptionStyle(mCaptionStyle); } @Override public void onFontScaleChanged(float fontScale) { mClosedCaptionLayout.setFontScale(fontScale); } }; private void manageChangeListener() { final boolean needsListener = isAttachedToWindow() && getVisibility() == View.VISIBLE; if (mHasChangeListener != needsListener) { mHasChangeListener = needsListener; if (needsListener) { mManager.addCaptioningChangeListener(mCaptioningListener); } else { mManager.removeCaptioningChangeListener(mCaptioningListener); } } } } /** * @hide * * CCParser processes CEA-608 closed caption data. * * It calls back into OnDisplayChangedListener upon * display change with styled text for rendering. * */ class Cea608CCParser { public static final int MAX_ROWS = 15; public static final int MAX_COLS = 32; private static final String TAG = "Cea608CCParser"; private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); private static final int INVALID = -1; // EIA-CEA-608: Table 70 - Control Codes private static final int RCL = 0x20; private static final int BS = 0x21; private static final int AOF = 0x22; private static final int AON = 0x23; private static final int DER = 0x24; private static final int RU2 = 0x25; private static final int RU3 = 0x26; private static final int RU4 = 0x27; private static final int FON = 0x28; private static final int RDC = 0x29; private static final int TR = 0x2a; private static final int RTD = 0x2b; private static final int EDM = 0x2c; private static final int CR = 0x2d; private static final int ENM = 0x2e; private static final int EOC = 0x2f; // Transparent Space private static final char TS = '\u00A0'; // Captioning Modes private static final int MODE_UNKNOWN = 0; private static final int MODE_PAINT_ON = 1; private static final int MODE_ROLL_UP = 2; private static final int MODE_POP_ON = 3; private static final int MODE_TEXT = 4; private final DisplayListener mListener; private int mMode = MODE_PAINT_ON; private int mRollUpSize = 4; private int mPrevCtrlCode = INVALID; private CCMemory mDisplay = new CCMemory(); private CCMemory mNonDisplay = new CCMemory(); private CCMemory mTextMem = new CCMemory(); Cea608CCParser(DisplayListener listener) { mListener = listener; } public void parse(byte[] data) { CCData[] ccData = CCData.fromByteArray(data); for (int i = 0; i < ccData.length; i++) { if (DEBUG) { Log.d(TAG, ccData[i].toString()); } if (handleCtrlCode(ccData[i]) || handleTabOffsets(ccData[i]) || handlePACCode(ccData[i]) || handleMidRowCode(ccData[i])) { continue; } handleDisplayableChars(ccData[i]); } } interface DisplayListener { void onDisplayChanged(SpannableStringBuilder[] styledTexts); CaptionStyle getCaptionStyle(); } private CCMemory getMemory() { // get the CC memory to operate on for current mode switch (mMode) { case MODE_POP_ON: return mNonDisplay; case MODE_TEXT: // TODO(chz): support only caption mode for now, // in text mode, dump everything to text mem. return mTextMem; case MODE_PAINT_ON: case MODE_ROLL_UP: return mDisplay; default: Log.w(TAG, "unrecoginized mode: " + mMode); } return mDisplay; } private boolean handleDisplayableChars(CCData ccData) { if (!ccData.isDisplayableChar()) { return false; } // Extended char includes 1 automatic backspace if (ccData.isExtendedChar()) { getMemory().bs(); } getMemory().writeText(ccData.getDisplayText()); if (mMode == MODE_PAINT_ON || mMode == MODE_ROLL_UP) { updateDisplay(); } return true; } private boolean handleMidRowCode(CCData ccData) { StyleCode m = ccData.getMidRow(); if (m != null) { getMemory().writeMidRowCode(m); return true; } return false; } private boolean handlePACCode(CCData ccData) { PAC pac = ccData.getPAC(); if (pac != null) { if (mMode == MODE_ROLL_UP) { getMemory().moveBaselineTo(pac.getRow(), mRollUpSize); } getMemory().writePAC(pac); return true; } return false; } private boolean handleTabOffsets(CCData ccData) { int tabs = ccData.getTabOffset(); if (tabs > 0) { getMemory().tab(tabs); return true; } return false; } private boolean handleCtrlCode(CCData ccData) { int ctrlCode = ccData.getCtrlCode(); if (mPrevCtrlCode != INVALID && mPrevCtrlCode == ctrlCode) { // discard double ctrl codes (but if there's a 3rd one, we still take that) mPrevCtrlCode = INVALID; return true; } switch(ctrlCode) { case RCL: // select pop-on style mMode = MODE_POP_ON; break; case BS: getMemory().bs(); break; case DER: getMemory().der(); break; case RU2: case RU3: case RU4: mRollUpSize = (ctrlCode - 0x23); // erase memory if currently in other style if (mMode != MODE_ROLL_UP) { mDisplay.erase(); mNonDisplay.erase(); } // select roll-up style mMode = MODE_ROLL_UP; break; case FON: Log.i(TAG, "Flash On"); break; case RDC: // select paint-on style mMode = MODE_PAINT_ON; break; case TR: mMode = MODE_TEXT; mTextMem.erase(); break; case RTD: mMode = MODE_TEXT; break; case EDM: // erase display memory mDisplay.erase(); updateDisplay(); break; case CR: if (mMode == MODE_ROLL_UP) { getMemory().rollUp(mRollUpSize); } else { getMemory().cr(); } if (mMode == MODE_ROLL_UP) { updateDisplay(); } break; case ENM: // erase non-display memory mNonDisplay.erase(); break; case EOC: // swap display/non-display memory swapMemory(); // switch to pop-on style mMode = MODE_POP_ON; updateDisplay(); break; case INVALID: default: mPrevCtrlCode = INVALID; return false; } mPrevCtrlCode = ctrlCode; // handled return true; } private void updateDisplay() { if (mListener != null) { CaptionStyle captionStyle = mListener.getCaptionStyle(); mListener.onDisplayChanged(mDisplay.getStyledText(captionStyle)); } } private void swapMemory() { CCMemory temp = mDisplay; mDisplay = mNonDisplay; mNonDisplay = temp; } private static class StyleCode { static final int COLOR_WHITE = 0; static final int COLOR_GREEN = 1; static final int COLOR_BLUE = 2; static final int COLOR_CYAN = 3; static final int COLOR_RED = 4; static final int COLOR_YELLOW = 5; static final int COLOR_MAGENTA = 6; static final int COLOR_INVALID = 7; static final int STYLE_ITALICS = 0x00000001; static final int STYLE_UNDERLINE = 0x00000002; static final String[] mColorMap = { "WHITE", "GREEN", "BLUE", "CYAN", "RED", "YELLOW", "MAGENTA", "INVALID" }; final int mStyle; final int mColor; static StyleCode fromByte(byte data2) { int style = 0; int color = (data2 >> 1) & 0x7; if ((data2 & 0x1) != 0) { style |= STYLE_UNDERLINE; } if (color == COLOR_INVALID) { // WHITE ITALICS color = COLOR_WHITE; style |= STYLE_ITALICS; } return new StyleCode(style, color); } StyleCode(int style, int color) { mStyle = style; mColor = color; } boolean isItalics() { return (mStyle & STYLE_ITALICS) != 0; } boolean isUnderline() { return (mStyle & STYLE_UNDERLINE) != 0; } int getColor() { return mColor; } @Override public String toString() { StringBuilder str = new StringBuilder(); str.append("{"); str.append(mColorMap[mColor]); if ((mStyle & STYLE_ITALICS) != 0) { str.append(", ITALICS"); } if ((mStyle & STYLE_UNDERLINE) != 0) { str.append(", UNDERLINE"); } str.append("}"); return str.toString(); } } private static class PAC extends StyleCode { final int mRow; final int mCol; static PAC fromBytes(byte data1, byte data2) { int[] rowTable = {11, 1, 3, 12, 14, 5, 7, 9}; int row = rowTable[data1 & 0x07] + ((data2 & 0x20) >> 5); int style = 0; if ((data2 & 1) != 0) { style |= STYLE_UNDERLINE; } if ((data2 & 0x10) != 0) { // indent code int indent = (data2 >> 1) & 0x7; return new PAC(row, indent * 4, style, COLOR_WHITE); } else { // style code int color = (data2 >> 1) & 0x7; if (color == COLOR_INVALID) { // WHITE ITALICS color = COLOR_WHITE; style |= STYLE_ITALICS; } return new PAC(row, -1, style, color); } } PAC(int row, int col, int style, int color) { super(style, color); mRow = row; mCol = col; } boolean isIndentPAC() { return (mCol >= 0); } int getRow() { return mRow; } int getCol() { return mCol; } @Override public String toString() { return String.format("{%d, %d}, %s", mRow, mCol, super.toString()); } } /** * Mutable version of BackgroundSpan to facilitate text rendering with edge styles. * * @hide */ public static class MutableBackgroundColorSpan extends CharacterStyle implements UpdateAppearance { private int mColor; public MutableBackgroundColorSpan(int color) { mColor = color; } public void setBackgroundColor(int color) { mColor = color; } public int getBackgroundColor() { return mColor; } @Override public void updateDrawState(TextPaint ds) { ds.bgColor = mColor; } } /* CCLineBuilder keeps track of displayable chars, as well as * MidRow styles and PACs, for a single line of CC memory. * * It generates styled text via getStyledText() method. */ private static class CCLineBuilder { private final StringBuilder mDisplayChars; private final StyleCode[] mMidRowStyles; private final StyleCode[] mPACStyles; CCLineBuilder(String str) { mDisplayChars = new StringBuilder(str); mMidRowStyles = new StyleCode[mDisplayChars.length()]; mPACStyles = new StyleCode[mDisplayChars.length()]; } void setCharAt(int index, char ch) { mDisplayChars.setCharAt(index, ch); mMidRowStyles[index] = null; } void setMidRowAt(int index, StyleCode m) { mDisplayChars.setCharAt(index, ' '); mMidRowStyles[index] = m; } void setPACAt(int index, PAC pac) { mPACStyles[index] = pac; } char charAt(int index) { return mDisplayChars.charAt(index); } int length() { return mDisplayChars.length(); } void applyStyleSpan( SpannableStringBuilder styledText, StyleCode s, int start, int end) { if (s.isItalics()) { styledText.setSpan( new StyleSpan(android.graphics.Typeface.ITALIC), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } if (s.isUnderline()) { styledText.setSpan( new UnderlineSpan(), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); } } SpannableStringBuilder getStyledText(CaptionStyle captionStyle) { SpannableStringBuilder styledText = new SpannableStringBuilder(mDisplayChars); int start = -1, next = 0; int styleStart = -1; StyleCode curStyle = null; while (next < mDisplayChars.length()) { StyleCode newStyle = null; if (mMidRowStyles[next] != null) { // apply mid-row style change newStyle = mMidRowStyles[next]; } else if (mPACStyles[next] != null && (styleStart < 0 || start < 0)) { // apply PAC style change, only if: // 1. no style set, or // 2. style set, but prev char is none-displayable newStyle = mPACStyles[next]; } if (newStyle != null) { curStyle = newStyle; if (styleStart >= 0 && start >= 0) { applyStyleSpan(styledText, newStyle, styleStart, next); } styleStart = next; } if (mDisplayChars.charAt(next) != TS) { if (start < 0) { start = next; } } else if (start >= 0) { int expandedStart = mDisplayChars.charAt(start) == ' ' ? start : start - 1; int expandedEnd = mDisplayChars.charAt(next - 1) == ' ' ? next : next + 1; styledText.setSpan( new MutableBackgroundColorSpan(captionStyle.backgroundColor), expandedStart, expandedEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); if (styleStart >= 0) { applyStyleSpan(styledText, curStyle, styleStart, expandedEnd); } start = -1; } next++; } return styledText; } } /* * CCMemory models a console-style display. */ private static class CCMemory { private final String mBlankLine; private final CCLineBuilder[] mLines = new CCLineBuilder[MAX_ROWS + 2]; private int mRow; private int mCol; CCMemory() { char[] blank = new char[MAX_COLS + 2]; Arrays.fill(blank, TS); mBlankLine = new String(blank); } void erase() { // erase all lines for (int i = 0; i < mLines.length; i++) { mLines[i] = null; } mRow = MAX_ROWS; mCol = 1; } void der() { if (mLines[mRow] != null) { for (int i = 0; i < mCol; i++) { if (mLines[mRow].charAt(i) != TS) { for (int j = mCol; j < mLines[mRow].length(); j++) { mLines[j].setCharAt(j, TS); } return; } } mLines[mRow] = null; } } void tab(int tabs) { moveCursorByCol(tabs); } void bs() { moveCursorByCol(-1); if (mLines[mRow] != null) { mLines[mRow].setCharAt(mCol, TS); if (mCol == MAX_COLS - 1) { // Spec recommendation: // if cursor was at col 32, move cursor // back to col 31 and erase both col 31&32 mLines[mRow].setCharAt(MAX_COLS, TS); } } } void cr() { moveCursorTo(mRow + 1, 1); } void rollUp(int windowSize) { int i; for (i = 0; i <= mRow - windowSize; i++) { mLines[i] = null; } int startRow = mRow - windowSize + 1; if (startRow < 1) { startRow = 1; } for (i = startRow; i < mRow; i++) { mLines[i] = mLines[i + 1]; } for (i = mRow; i < mLines.length; i++) { // clear base row mLines[i] = null; } // default to col 1, in case PAC is not sent mCol = 1; } void writeText(String text) { for (int i = 0; i < text.length(); i++) { getLineBuffer(mRow).setCharAt(mCol, text.charAt(i)); moveCursorByCol(1); } } void writeMidRowCode(StyleCode m) { getLineBuffer(mRow).setMidRowAt(mCol, m); moveCursorByCol(1); } void writePAC(PAC pac) { if (pac.isIndentPAC()) { moveCursorTo(pac.getRow(), pac.getCol()); } else { moveCursorTo(pac.getRow(), 1); } getLineBuffer(mRow).setPACAt(mCol, pac); } SpannableStringBuilder[] getStyledText(CaptionStyle captionStyle) { ArrayList rows = new ArrayList<>(MAX_ROWS); for (int i = 1; i <= MAX_ROWS; i++) { rows.add(mLines[i] != null ? mLines[i].getStyledText(captionStyle) : null); } return rows.toArray(new SpannableStringBuilder[MAX_ROWS]); } private static int clamp(int x, int min, int max) { return x < min ? min : (x > max ? max : x); } private void moveCursorTo(int row, int col) { mRow = clamp(row, 1, MAX_ROWS); mCol = clamp(col, 1, MAX_COLS); } private void moveCursorToRow(int row) { mRow = clamp(row, 1, MAX_ROWS); } private void moveCursorByCol(int col) { mCol = clamp(mCol + col, 1, MAX_COLS); } private void moveBaselineTo(int baseRow, int windowSize) { if (mRow == baseRow) { return; } int actualWindowSize = windowSize; if (baseRow < actualWindowSize) { actualWindowSize = baseRow; } if (mRow < actualWindowSize) { actualWindowSize = mRow; } int i; if (baseRow < mRow) { // copy from bottom to top row for (i = actualWindowSize - 1; i >= 0; i--) { mLines[baseRow - i] = mLines[mRow - i]; } } else { // copy from top to bottom row for (i = 0; i < actualWindowSize; i++) { mLines[baseRow - i] = mLines[mRow - i]; } } // clear rest of the rows for (i = 0; i <= baseRow - windowSize; i++) { mLines[i] = null; } for (i = baseRow + 1; i < mLines.length; i++) { mLines[i] = null; } } private CCLineBuilder getLineBuffer(int row) { if (mLines[row] == null) { mLines[row] = new CCLineBuilder(mBlankLine); } return mLines[row]; } } /* * CCData parses the raw CC byte pair into displayable chars, * misc control codes, Mid-Row or Preamble Address Codes. */ private static class CCData { private final byte mType; private final byte mData1; private final byte mData2; private static final String[] mCtrlCodeMap = { "RCL", "BS" , "AOF", "AON", "DER", "RU2", "RU3", "RU4", "FON", "RDC", "TR" , "RTD", "EDM", "CR" , "ENM", "EOC", }; private static final String[] mSpecialCharMap = { "\u00AE", "\u00B0", "\u00BD", "\u00BF", "\u2122", "\u00A2", "\u00A3", "\u266A", // Eighth note "\u00E0", "\u00A0", // Transparent space "\u00E8", "\u00E2", "\u00EA", "\u00EE", "\u00F4", "\u00FB", }; private static final String[] mSpanishCharMap = { // Spanish and misc chars "\u00C1", // A "\u00C9", // E "\u00D3", // I "\u00DA", // O "\u00DC", // U "\u00FC", // u "\u2018", // opening single quote "\u00A1", // inverted exclamation mark "*", "'", "\u2014", // em dash "\u00A9", // Copyright "\u2120", // Servicemark "\u2022", // round bullet "\u201C", // opening double quote "\u201D", // closing double quote // French "\u00C0", "\u00C2", "\u00C7", "\u00C8", "\u00CA", "\u00CB", "\u00EB", "\u00CE", "\u00CF", "\u00EF", "\u00D4", "\u00D9", "\u00F9", "\u00DB", "\u00AB", "\u00BB" }; private static final String[] mProtugueseCharMap = { // Portuguese "\u00C3", "\u00E3", "\u00CD", "\u00CC", "\u00EC", "\u00D2", "\u00F2", "\u00D5", "\u00F5", "{", "}", "\\", "^", "_", "|", "~", // German and misc chars "\u00C4", "\u00E4", "\u00D6", "\u00F6", "\u00DF", "\u00A5", "\u00A4", "\u2502", // vertical bar "\u00C5", "\u00E5", "\u00D8", "\u00F8", "\u250C", // top-left corner "\u2510", // top-right corner "\u2514", // lower-left corner "\u2518", // lower-right corner }; static CCData[] fromByteArray(byte[] data) { CCData[] ccData = new CCData[data.length / 3]; for (int i = 0; i < ccData.length; i++) { ccData[i] = new CCData( data[i * 3], data[i * 3 + 1], data[i * 3 + 2]); } return ccData; } CCData(byte type, byte data1, byte data2) { mType = type; mData1 = data1; mData2 = data2; } int getCtrlCode() { if ((mData1 == 0x14 || mData1 == 0x1c) && mData2 >= 0x20 && mData2 <= 0x2f) { return mData2; } return INVALID; } StyleCode getMidRow() { // only support standard Mid-row codes, ignore // optional background/foreground mid-row codes if ((mData1 == 0x11 || mData1 == 0x19) && mData2 >= 0x20 && mData2 <= 0x2f) { return StyleCode.fromByte(mData2); } return null; } PAC getPAC() { if ((mData1 & 0x70) == 0x10 && (mData2 & 0x40) == 0x40 && ((mData1 & 0x07) != 0 || (mData2 & 0x20) == 0)) { return PAC.fromBytes(mData1, mData2); } return null; } int getTabOffset() { if ((mData1 == 0x17 || mData1 == 0x1f) && mData2 >= 0x21 && mData2 <= 0x23) { return mData2 & 0x3; } return 0; } boolean isDisplayableChar() { return isBasicChar() || isSpecialChar() || isExtendedChar(); } String getDisplayText() { String str = getBasicChars(); if (str == null) { str = getSpecialChar(); if (str == null) { str = getExtendedChar(); } } return str; } private String ctrlCodeToString(int ctrlCode) { return mCtrlCodeMap[ctrlCode - 0x20]; } private boolean isBasicChar() { return mData1 >= 0x20 && mData1 <= 0x7f; } private boolean isSpecialChar() { return ((mData1 == 0x11 || mData1 == 0x19) && mData2 >= 0x30 && mData2 <= 0x3f); } private boolean isExtendedChar() { return ((mData1 == 0x12 || mData1 == 0x1A || mData1 == 0x13 || mData1 == 0x1B) && mData2 >= 0x20 && mData2 <= 0x3f); } private char getBasicChar(byte data) { char c; // replace the non-ASCII ones switch (data) { case 0x2A: c = '\u00E1'; break; case 0x5C: c = '\u00E9'; break; case 0x5E: c = '\u00ED'; break; case 0x5F: c = '\u00F3'; break; case 0x60: c = '\u00FA'; break; case 0x7B: c = '\u00E7'; break; case 0x7C: c = '\u00F7'; break; case 0x7D: c = '\u00D1'; break; case 0x7E: c = '\u00F1'; break; case 0x7F: c = '\u2588'; break; // Full block default: c = (char) data; break; } return c; } private String getBasicChars() { if (mData1 >= 0x20 && mData1 <= 0x7f) { StringBuilder builder = new StringBuilder(2); builder.append(getBasicChar(mData1)); if (mData2 >= 0x20 && mData2 <= 0x7f) { builder.append(getBasicChar(mData2)); } return builder.toString(); } return null; } private String getSpecialChar() { if ((mData1 == 0x11 || mData1 == 0x19) && mData2 >= 0x30 && mData2 <= 0x3f) { return mSpecialCharMap[mData2 - 0x30]; } return null; } private String getExtendedChar() { if ((mData1 == 0x12 || mData1 == 0x1A) && mData2 >= 0x20 && mData2 <= 0x3f){ // 1 Spanish/French char return mSpanishCharMap[mData2 - 0x20]; } else if ((mData1 == 0x13 || mData1 == 0x1B) && mData2 >= 0x20 && mData2 <= 0x3f){ // 1 Portuguese/German/Danish char return mProtugueseCharMap[mData2 - 0x20]; } return null; } @Override public String toString() { String str; if (mData1 < 0x10 && mData2 < 0x10) { // Null Pad, ignore return String.format("[%d]Null: %02x %02x", mType, mData1, mData2); } int ctrlCode = getCtrlCode(); if (ctrlCode != INVALID) { return String.format("[%d]%s", mType, ctrlCodeToString(ctrlCode)); } int tabOffset = getTabOffset(); if (tabOffset > 0) { return String.format("[%d]Tab%d", mType, tabOffset); } PAC pac = getPAC(); if (pac != null) { return String.format("[%d]PAC: %s", mType, pac.toString()); } StyleCode m = getMidRow(); if (m != null) { return String.format("[%d]Mid-row: %s", mType, m.toString()); } if (isDisplayableChar()) { return String.format("[%d]Displayable: %s (%02x %02x)", mType, getDisplayText(), mData1, mData2); } return String.format("[%d]Invalid: %02x %02x", mType, mData1, mData2); } } } /** * Widget capable of rendering CEA-608 closed captions. * * @hide */ class Cea608CCWidget extends ClosedCaptionWidget implements Cea608CCParser.DisplayListener { private static final Rect mTextBounds = new Rect(); private static final String mDummyText = "1234567890123456789012345678901234"; public Cea608CCWidget(Context context) { this(context, null); } public Cea608CCWidget(Context context, AttributeSet attrs) { this(context, attrs, 0); } public Cea608CCWidget(Context context, AttributeSet attrs, int defStyle) { this(context, attrs, defStyle, 0); } public Cea608CCWidget(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override public ClosedCaptionLayout createCaptionLayout(Context context) { return new CCLayout(context); } @Override public void onDisplayChanged(SpannableStringBuilder[] styledTexts) { ((CCLayout) mClosedCaptionLayout).update(styledTexts); if (mListener != null) { mListener.onChanged(this); } } @Override public CaptionStyle getCaptionStyle() { return mCaptionStyle; } private static class CCLineBox extends TextView { private static final float FONT_PADDING_RATIO = 0.75f; private static final float EDGE_OUTLINE_RATIO = 0.1f; private static final float EDGE_SHADOW_RATIO = 0.05f; private float mOutlineWidth; private float mShadowRadius; private float mShadowOffset; private int mTextColor = Color.WHITE; private int mBgColor = Color.BLACK; private int mEdgeType = CaptionStyle.EDGE_TYPE_NONE; private int mEdgeColor = Color.TRANSPARENT; CCLineBox(Context context) { super(context); setGravity(Gravity.CENTER); setBackgroundColor(Color.TRANSPARENT); setTextColor(Color.WHITE); setTypeface(Typeface.MONOSPACE); setVisibility(View.INVISIBLE); final Resources res = getContext().getResources(); // get the default (will be updated later during measure) mOutlineWidth = res.getDimensionPixelSize( com.android.internal.R.dimen.subtitle_outline_width); mShadowRadius = res.getDimensionPixelSize( com.android.internal.R.dimen.subtitle_shadow_radius); mShadowOffset = res.getDimensionPixelSize( com.android.internal.R.dimen.subtitle_shadow_offset); } void setCaptionStyle(CaptionStyle captionStyle) { mTextColor = captionStyle.foregroundColor; mBgColor = captionStyle.backgroundColor; mEdgeType = captionStyle.edgeType; mEdgeColor = captionStyle.edgeColor; setTextColor(mTextColor); if (mEdgeType == CaptionStyle.EDGE_TYPE_DROP_SHADOW) { setShadowLayer(mShadowRadius, mShadowOffset, mShadowOffset, mEdgeColor); } else { setShadowLayer(0, 0, 0, 0); } invalidate(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { float fontSize = MeasureSpec.getSize(heightMeasureSpec) * FONT_PADDING_RATIO; setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize); mOutlineWidth = EDGE_OUTLINE_RATIO * fontSize + 1.0f; mShadowRadius = EDGE_SHADOW_RATIO * fontSize + 1.0f;; mShadowOffset = mShadowRadius; // set font scale in the X direction to match the required width setScaleX(1.0f); getPaint().getTextBounds(mDummyText, 0, mDummyText.length(), mTextBounds); float actualTextWidth = mTextBounds.width(); float requiredTextWidth = MeasureSpec.getSize(widthMeasureSpec); setScaleX(requiredTextWidth / actualTextWidth); super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override protected void onDraw(Canvas c) { if (mEdgeType == CaptionStyle.EDGE_TYPE_UNSPECIFIED || mEdgeType == CaptionStyle.EDGE_TYPE_NONE || mEdgeType == CaptionStyle.EDGE_TYPE_DROP_SHADOW) { // these edge styles don't require a second pass super.onDraw(c); return; } if (mEdgeType == CaptionStyle.EDGE_TYPE_OUTLINE) { drawEdgeOutline(c); } else { // Raised or depressed drawEdgeRaisedOrDepressed(c); } } private void drawEdgeOutline(Canvas c) { TextPaint textPaint = getPaint(); Paint.Style previousStyle = textPaint.getStyle(); Paint.Join previousJoin = textPaint.getStrokeJoin(); float previousWidth = textPaint.getStrokeWidth(); setTextColor(mEdgeColor); textPaint.setStyle(Paint.Style.FILL_AND_STROKE); textPaint.setStrokeJoin(Paint.Join.ROUND); textPaint.setStrokeWidth(mOutlineWidth); // Draw outline and background only. super.onDraw(c); // Restore original settings. setTextColor(mTextColor); textPaint.setStyle(previousStyle); textPaint.setStrokeJoin(previousJoin); textPaint.setStrokeWidth(previousWidth); // Remove the background. setBackgroundSpans(Color.TRANSPARENT); // Draw foreground only. super.onDraw(c); // Restore the background. setBackgroundSpans(mBgColor); } private void drawEdgeRaisedOrDepressed(Canvas c) { TextPaint textPaint = getPaint(); Paint.Style previousStyle = textPaint.getStyle(); textPaint.setStyle(Paint.Style.FILL); final boolean raised = mEdgeType == CaptionStyle.EDGE_TYPE_RAISED; final int colorUp = raised ? Color.WHITE : mEdgeColor; final int colorDown = raised ? mEdgeColor : Color.WHITE; final float offset = mShadowRadius / 2f; // Draw background and text with shadow up setShadowLayer(mShadowRadius, -offset, -offset, colorUp); super.onDraw(c); // Remove the background. setBackgroundSpans(Color.TRANSPARENT); // Draw text with shadow down setShadowLayer(mShadowRadius, +offset, +offset, colorDown); super.onDraw(c); // Restore settings textPaint.setStyle(previousStyle); // Restore the background. setBackgroundSpans(mBgColor); } private void setBackgroundSpans(int color) { CharSequence text = getText(); if (text instanceof Spannable) { Spannable spannable = (Spannable) text; Cea608CCParser.MutableBackgroundColorSpan[] bgSpans = spannable.getSpans( 0, spannable.length(), Cea608CCParser.MutableBackgroundColorSpan.class); for (int i = 0; i < bgSpans.length; i++) { bgSpans[i].setBackgroundColor(color); } } } } private static class CCLayout extends LinearLayout implements ClosedCaptionLayout { private static final int MAX_ROWS = Cea608CCParser.MAX_ROWS; private static final float SAFE_AREA_RATIO = 0.9f; private final CCLineBox[] mLineBoxes = new CCLineBox[MAX_ROWS]; CCLayout(Context context) { super(context); setGravity(Gravity.START); setOrientation(LinearLayout.VERTICAL); for (int i = 0; i < MAX_ROWS; i++) { mLineBoxes[i] = new CCLineBox(getContext()); addView(mLineBoxes[i], LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); } } @Override public void setCaptionStyle(CaptionStyle captionStyle) { for (int i = 0; i < MAX_ROWS; i++) { mLineBoxes[i].setCaptionStyle(captionStyle); } } @Override public void setFontScale(float fontScale) { // Ignores the font scale changes of the system wide CC preference. } void update(SpannableStringBuilder[] textBuffer) { for (int i = 0; i < MAX_ROWS; i++) { if (textBuffer[i] != null) { mLineBoxes[i].setText(textBuffer[i], TextView.BufferType.SPANNABLE); mLineBoxes[i].setVisibility(View.VISIBLE); } else { mLineBoxes[i].setVisibility(View.INVISIBLE); } } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int safeWidth = getMeasuredWidth(); int safeHeight = getMeasuredHeight(); // CEA-608 assumes 4:3 video if (safeWidth * 3 >= safeHeight * 4) { safeWidth = safeHeight * 4 / 3; } else { safeHeight = safeWidth * 3 / 4; } safeWidth *= SAFE_AREA_RATIO; safeHeight *= SAFE_AREA_RATIO; int lineHeight = safeHeight / MAX_ROWS; int lineHeightMeasureSpec = MeasureSpec.makeMeasureSpec( lineHeight, MeasureSpec.EXACTLY); int lineWidthMeasureSpec = MeasureSpec.makeMeasureSpec( safeWidth, MeasureSpec.EXACTLY); for (int i = 0; i < MAX_ROWS; i++) { mLineBoxes[i].measure(lineWidthMeasureSpec, lineHeightMeasureSpec); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { // safe caption area int viewPortWidth = r - l; int viewPortHeight = b - t; int safeWidth, safeHeight; // CEA-608 assumes 4:3 video if (viewPortWidth * 3 >= viewPortHeight * 4) { safeWidth = viewPortHeight * 4 / 3; safeHeight = viewPortHeight; } else { safeWidth = viewPortWidth; safeHeight = viewPortWidth * 3 / 4; } safeWidth *= SAFE_AREA_RATIO; safeHeight *= SAFE_AREA_RATIO; int left = (viewPortWidth - safeWidth) / 2; int top = (viewPortHeight - safeHeight) / 2; for (int i = 0; i < MAX_ROWS; i++) { mLineBoxes[i].layout( left, top + safeHeight * i / MAX_ROWS, left + safeWidth, top + safeHeight * (i + 1) / MAX_ROWS); } } } }