/*
* Copyright 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media2.player.subtitle;
import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.media.MediaFormat;
import android.os.Build;
import android.os.Handler;
import android.os.Message;
import android.text.Layout.Alignment;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.CharacterStyle;
import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import android.text.style.SubscriptSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.UnderlineSpan;
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.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.RestrictTo;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
// Note: This is forked from android.media.Cea708CaptionRenderer since P
/** @hide */
@RestrictTo(LIBRARY_GROUP_PREFIX)
public class Cea708CaptionRenderer extends SubtitleController.Renderer {
private final Context mContext;
private Cea708CCWidget mCCWidget;
public Cea708CaptionRenderer(Context context) {
mContext = context;
}
@Override
public boolean supports(MediaFormat format) {
if (format.containsKey(MediaFormat.KEY_MIME)) {
String mimeType = format.getString(MediaFormat.KEY_MIME);
return MediaFormat.MIMETYPE_TEXT_CEA_708.equals(mimeType);
}
return false;
}
@Override
public SubtitleTrack createTrack(MediaFormat format) {
String mimeType = format.getString(MediaFormat.KEY_MIME);
if (MediaFormat.MIMETYPE_TEXT_CEA_708.equals(mimeType)) {
if (mCCWidget == null) {
mCCWidget = new Cea708CCWidget(mContext);
}
return new Cea708CaptionTrack(mCCWidget, format);
}
throw new RuntimeException("No matching format: " + format.toString());
}
static class Cea708CaptionTrack extends SubtitleTrack {
private final Cea708CCParser mCCParser;
private final Cea708CCWidget mRenderingWidget;
Cea708CaptionTrack(Cea708CCWidget renderingWidget, MediaFormat format) {
super(format);
mRenderingWidget = renderingWidget;
mCCParser = new Cea708CCParser(mRenderingWidget);
}
@Override
public void onData(byte[] data, boolean eos, long runID) {
mCCParser.parse(data);
}
@Override
public RenderingWidget getRenderingWidget() {
return mRenderingWidget;
}
@Override
public void updateView(ArrayList<Cue> activeCues) {
// Overriding with NO-OP, CC rendering by-passes this
}
}
/**
* Widget capable of rendering CEA-708 closed captions.
*/
class Cea708CCWidget extends ClosedCaptionWidget implements Cea708CCParser.DisplayListener {
private final CCHandler mCCHandler;
Cea708CCWidget(Context context) {
this(context, null);
}
Cea708CCWidget(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
Cea708CCWidget(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mCCHandler = new CCHandler((CCLayout) mClosedCaptionLayout);
}
@Override
public ClosedCaptionLayout createCaptionLayout(Context context) {
return new CCLayout(context);
}
@Override
public void emitEvent(Cea708CCParser.CaptionEvent event) {
mCCHandler.processCaptionEvent(event);
setSize(getWidth(), getHeight());
if (mListener != null) {
mListener.onChanged(this);
}
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
((ViewGroup) mClosedCaptionLayout).draw(canvas);
}
/**
* A layout that scales its children using the given percentage value.
*/
class ScaledLayout extends ViewGroup {
private static final String TAG = "ScaledLayout";
private static final boolean DEBUG = false;
private final Comparator<Rect> mRectTopLeftSorter = new Comparator<Rect>() {
@Override
public int compare(Rect lhs, Rect rhs) {
if (lhs.top != rhs.top) {
return lhs.top - rhs.top;
} else {
return lhs.left - rhs.left;
}
}
};
private Rect[] mRectArray;
ScaledLayout(Context context) {
super(context);
}
/**
* ScaledLayoutParams stores the four scale factors.
* <br>
* Vertical coordinate system: (scaleStartRow * 100) % ~ (scaleEndRow * 100) %
* Horizontal coordinate system: (scaleStartCol * 100) % ~ (scaleEndCol * 100) %
* <br>
* In XML, for example,
* <pre>
* {@code
* <View
* app:layout_scaleStartRow="0.1"
* app:layout_scaleEndRow="0.5"
* app:layout_scaleStartCol="0.4"
* app:layout_scaleEndCol="1" />
* }
* </pre>
*/
class ScaledLayoutParams extends ViewGroup.LayoutParams {
public static final float SCALE_UNSPECIFIED = -1;
public float scaleStartRow;
public float scaleEndRow;
public float scaleStartCol;
public float scaleEndCol;
ScaledLayoutParams(float scaleStartRow, float scaleEndRow,
float scaleStartCol, float scaleEndCol) {
super(MATCH_PARENT, MATCH_PARENT);
this.scaleStartRow = scaleStartRow;
this.scaleEndRow = scaleEndRow;
this.scaleStartCol = scaleStartCol;
this.scaleEndCol = scaleEndCol;
}
ScaledLayoutParams(Context context, AttributeSet attrs) {
super(MATCH_PARENT, MATCH_PARENT);
}
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new ScaledLayoutParams(getContext(), attrs);
}
@Override
protected boolean checkLayoutParams(LayoutParams p) {
return (p instanceof ScaledLayoutParams);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int width = widthSpecSize - getPaddingLeft() - getPaddingRight();
int height = heightSpecSize - getPaddingTop() - getPaddingBottom();
if (DEBUG) {
Log.d(TAG, String.format("onMeasure width: %d, height: %d", width, height));
}
int count = getChildCount();
mRectArray = new Rect[count];
for (int i = 0; i < count; ++i) {
View child = getChildAt(i);
ViewGroup.LayoutParams params = child.getLayoutParams();
float scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol;
if (!(params instanceof ScaledLayoutParams)) {
throw new RuntimeException("A child of ScaledLayout cannot have the "
+ "UNSPECIFIED scale factors");
}
scaleStartRow = ((ScaledLayoutParams) params).scaleStartRow;
scaleEndRow = ((ScaledLayoutParams) params).scaleEndRow;
scaleStartCol = ((ScaledLayoutParams) params).scaleStartCol;
scaleEndCol = ((ScaledLayoutParams) params).scaleEndCol;
if (scaleStartRow < 0 || scaleStartRow > 1) {
throw new RuntimeException("A child of ScaledLayout should have a range of "
+ "scaleStartRow between 0 and 1");
}
if (scaleEndRow < scaleStartRow || scaleStartRow > 1) {
throw new RuntimeException("A child of ScaledLayout should have a range of "
+ "scaleEndRow between scaleStartRow and 1");
}
if (scaleEndCol < 0 || scaleEndCol > 1) {
throw new RuntimeException("A child of ScaledLayout should have a range of "
+ "scaleStartCol between 0 and 1");
}
if (scaleEndCol < scaleStartCol || scaleEndCol > 1) {
throw new RuntimeException("A child of ScaledLayout should have a range of "
+ "scaleEndCol between scaleStartCol and 1");
}
if (DEBUG) {
Log.d(TAG, String.format("onMeasure child scaleStartRow: %f scaleEndRow: "
+ "%f scaleStartCol: %f scaleEndCol: %f",
scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
}
mRectArray[i] = new Rect((int) (scaleStartCol * width), (int) (scaleStartRow
* height), (int) (scaleEndCol * width), (int) (scaleEndRow * height));
int childWidthSpec = MeasureSpec.makeMeasureSpec(
(int) (width * (scaleEndCol - scaleStartCol)), MeasureSpec.EXACTLY);
int childHeightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
child.measure(childWidthSpec, childHeightSpec);
// If the height of the measured child view is bigger than the height of the
// calculated region by the given ScaleLayoutParams, the height of the region
// should be increased to fit the size of the child view.
if (child.getMeasuredHeight() > mRectArray[i].height()) {
int overflowedHeight = child.getMeasuredHeight() - mRectArray[i].height();
overflowedHeight = (overflowedHeight + 1) / 2;
mRectArray[i].bottom += overflowedHeight;
mRectArray[i].top -= overflowedHeight;
if (mRectArray[i].top < 0) {
mRectArray[i].bottom -= mRectArray[i].top;
mRectArray[i].top = 0;
}
if (mRectArray[i].bottom > height) {
mRectArray[i].top -= mRectArray[i].bottom - height;
mRectArray[i].bottom = height;
}
}
childHeightSpec = MeasureSpec.makeMeasureSpec(
(int) (height * (scaleEndRow - scaleStartRow)), MeasureSpec.EXACTLY);
child.measure(childWidthSpec, childHeightSpec);
}
// Avoid overlapping rectangles.
// Step 1. Sort rectangles by position (top-left).
int visibleRectCount = 0;
int[] visibleRectGroup = new int[count];
Rect[] visibleRectArray = new Rect[count];
for (int i = 0; i < count; ++i) {
if (getChildAt(i).getVisibility() == View.VISIBLE) {
visibleRectGroup[visibleRectCount] = visibleRectCount;
visibleRectArray[visibleRectCount] = mRectArray[i];
++visibleRectCount;
}
}
Arrays.sort(visibleRectArray, 0, visibleRectCount, mRectTopLeftSorter);
// Step 2. Move down if there are overlapping rectangles.
for (int i = 0; i < visibleRectCount - 1; ++i) {
for (int j = i + 1; j < visibleRectCount; ++j) {
if (Rect.intersects(visibleRectArray[i], visibleRectArray[j])) {
visibleRectGroup[j] = visibleRectGroup[i];
visibleRectArray[j].set(visibleRectArray[j].left,
visibleRectArray[i].bottom,
visibleRectArray[j].right,
visibleRectArray[i].bottom + visibleRectArray[j].height());
}
}
}
// Step 3. Move up if there is any overflowed rectangle.
for (int i = visibleRectCount - 1; i >= 0; --i) {
if (visibleRectArray[i].bottom > height) {
int overflowedHeight = visibleRectArray[i].bottom - height;
for (int j = 0; j <= i; ++j) {
if (visibleRectGroup[i] == visibleRectGroup[j]) {
visibleRectArray[j].set(visibleRectArray[j].left,
visibleRectArray[j].top - overflowedHeight,
visibleRectArray[j].right,
visibleRectArray[j].bottom - overflowedHeight);
}
}
}
}
setMeasuredDimension(widthSpecSize, heightSpecSize);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int count = getChildCount();
for (int i = 0; i < count; ++i) {
View child = getChildAt(i);
if (child.getVisibility() != GONE) {
int childLeft = paddingLeft + mRectArray[i].left;
int childTop = paddingTop + mRectArray[i].top;
int childBottom = paddingLeft + mRectArray[i].bottom;
int childRight = paddingTop + mRectArray[i].right;
if (DEBUG) {
Log.d(TAG, String.format(
"child layout bottom: %d left: %d right: %d top: %d",
childBottom, childLeft, childRight, childTop));
}
child.layout(childLeft, childTop, childRight, childBottom);
}
}
}
@Override
public void dispatchDraw(Canvas canvas) {
int paddingLeft = getPaddingLeft();
int paddingTop = getPaddingTop();
int count = getChildCount();
for (int i = 0; i < count; ++i) {
View child = getChildAt(i);
if (child.getVisibility() != GONE) {
if (i >= mRectArray.length) {
break;
}
int childLeft = paddingLeft + mRectArray[i].left;
int childTop = paddingTop + mRectArray[i].top;
final int saveCount = canvas.save();
canvas.translate(childLeft, childTop);
child.draw(canvas);
canvas.restoreToCount(saveCount);
}
}
}
}
/**
* Layout containing the safe title area that helps the closed captions look more prominent.
*
* <p>This is required by CEA-708B.
*/
class CCLayout extends ScaledLayout implements ClosedCaptionLayout {
private static final float SAFE_TITLE_AREA_SCALE_START_X = 0.1f;
private static final float SAFE_TITLE_AREA_SCALE_END_X = 0.9f;
private static final float SAFE_TITLE_AREA_SCALE_START_Y = 0.1f;
private static final float SAFE_TITLE_AREA_SCALE_END_Y = 0.9f;
private final ScaledLayout mSafeTitleAreaLayout;
CCLayout(Context context) {
super(context);
mSafeTitleAreaLayout = new ScaledLayout(context);
addView(mSafeTitleAreaLayout, new ScaledLayout.ScaledLayoutParams(
SAFE_TITLE_AREA_SCALE_START_X, SAFE_TITLE_AREA_SCALE_END_X,
SAFE_TITLE_AREA_SCALE_START_Y, SAFE_TITLE_AREA_SCALE_END_Y));
}
public void addOrUpdateViewToSafeTitleArea(CCWindowLayout captionWindowLayout,
ScaledLayoutParams scaledLayoutParams) {
int index = mSafeTitleAreaLayout.indexOfChild(captionWindowLayout);
if (index < 0) {
mSafeTitleAreaLayout.addView(captionWindowLayout, scaledLayoutParams);
return;
}
mSafeTitleAreaLayout.updateViewLayout(captionWindowLayout, scaledLayoutParams);
}
public void removeViewFromSafeTitleArea(CCWindowLayout captionWindowLayout) {
mSafeTitleAreaLayout.removeView(captionWindowLayout);
}
@Override
public void setCaptionStyle(CaptionStyle style) {
final int count = mSafeTitleAreaLayout.getChildCount();
for (int i = 0; i < count; ++i) {
final CCWindowLayout windowLayout =
(CCWindowLayout) mSafeTitleAreaLayout.getChildAt(i);
windowLayout.setCaptionStyle(style);
}
}
@Override
public void setFontScale(float fontScale) {
final int count = mSafeTitleAreaLayout.getChildCount();
for (int i = 0; i < count; ++i) {
final CCWindowLayout windowLayout =
(CCWindowLayout) mSafeTitleAreaLayout.getChildAt(i);
windowLayout.setFontScale(fontScale);
}
}
}
/**
* Renders the selected CC track.
*/
class CCHandler implements Handler.Callback {
// TODO: Remaining works
// CaptionTrackRenderer does not support the full spec of CEA-708.
// The remaining works are described in the follows.
// C0 Table: Backspace, FF, and HCR are not supported. The rule for P16 is not
// standardized but it is handled as EUC-KR charset for Korea broadcasting.
// C1 Table: All the styles of windows and pens except underline, italic, pen size,
// and pen offset specified in CEA-708 are ignored and this follows system
// wide CC preferences for look and feel. SetPenLocation is not implemented.
// G2 Table: TSP, NBTSP and BLK are not supported.
// Text/commands: Word wrapping, fonts, row and column locking are not supported.
private static final String TAG = "CCHandler";
private static final boolean DEBUG = false;
private static final int TENTHS_OF_SECOND_IN_MILLIS = 100;
// According to CEA-708B, there can exist up to 8 caption windows.
private static final int CAPTION_WINDOWS_MAX = 8;
private static final int CAPTION_ALL_WINDOWS_BITMAP = 255;
private static final int MSG_DELAY_CANCEL = 1;
private static final int MSG_CAPTION_CLEAR = 2;
private static final long CAPTION_CLEAR_INTERVAL_MS = 60000;
private final CCLayout mCCLayout;
private boolean mIsDelayed = false;
private CCWindowLayout mCurrentWindowLayout;
private final CCWindowLayout[] mCaptionWindowLayouts =
new CCWindowLayout[CAPTION_WINDOWS_MAX];
private final ArrayList<Cea708CCParser.CaptionEvent> mPendingCaptionEvents =
new ArrayList<>();
private final Handler mHandler;
CCHandler(CCLayout ccLayout) {
mCCLayout = ccLayout;
mHandler = new Handler(this);
}
@Override
public boolean handleMessage(Message msg) {
switch (msg.what) {
case MSG_DELAY_CANCEL:
delayCancel();
return true;
case MSG_CAPTION_CLEAR:
clearWindows(CAPTION_ALL_WINDOWS_BITMAP);
return true;
}
return false;
}
public void processCaptionEvent(Cea708CCParser.CaptionEvent event) {
if (mIsDelayed) {
mPendingCaptionEvents.add(event);
return;
}
switch (event.type) {
case Cea708CCParser.CAPTION_EMIT_TYPE_BUFFER:
sendBufferToCurrentWindow((String) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_CONTROL:
sendControlToCurrentWindow((char) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_CWX:
setCurrentWindowLayout((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_CLW:
clearWindows((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_DSW:
displayWindows((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_HDW:
hideWindows((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_TGW:
toggleWindows((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_DLW:
deleteWindows((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_DLY:
delay((int) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_DLC:
delayCancel();
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_RST:
reset();
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_SPA:
setPenAttr((Cea708CCParser.CaptionPenAttr) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_SPC:
setPenColor((Cea708CCParser.CaptionPenColor) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_SPL:
setPenLocation((Cea708CCParser.CaptionPenLocation) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_SWA:
setWindowAttr((Cea708CCParser.CaptionWindowAttr) event.obj);
break;
case Cea708CCParser.CAPTION_EMIT_TYPE_COMMAND_DFX:
defineWindow((Cea708CCParser.CaptionWindow) event.obj);
break;
}
}
// The window related caption commands
private void setCurrentWindowLayout(int windowId) {
if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
return;
}
CCWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
if (windowLayout == null) {
return;
}
if (DEBUG) {
Log.d(TAG, "setCurrentWindowLayout to " + windowId);
}
mCurrentWindowLayout = windowLayout;
}
// Each bit of windowBitmap indicates a window.
// If a bit is set, the window id is the same as the number of the trailing zeros of the
// bit.
private ArrayList<CCWindowLayout> getWindowsFromBitmap(int windowBitmap) {
ArrayList<CCWindowLayout> windows = new ArrayList<>();
for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
if ((windowBitmap & (1 << i)) != 0) {
CCWindowLayout windowLayout = mCaptionWindowLayouts[i];
if (windowLayout != null) {
windows.add(windowLayout);
}
}
}
return windows;
}
private void clearWindows(int windowBitmap) {
if (windowBitmap == 0) {
return;
}
for (CCWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
windowLayout.clear();
}
}
private void displayWindows(int windowBitmap) {
if (windowBitmap == 0) {
return;
}
for (CCWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
windowLayout.show();
}
}
private void hideWindows(int windowBitmap) {
if (windowBitmap == 0) {
return;
}
for (CCWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
windowLayout.hide();
}
}
private void toggleWindows(int windowBitmap) {
if (windowBitmap == 0) {
return;
}
for (CCWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
if (windowLayout.isShown()) {
windowLayout.hide();
} else {
windowLayout.show();
}
}
}
private void deleteWindows(int windowBitmap) {
if (windowBitmap == 0) {
return;
}
for (CCWindowLayout windowLayout : getWindowsFromBitmap(windowBitmap)) {
windowLayout.removeFromCaptionView();
mCaptionWindowLayouts[windowLayout.getCaptionWindowId()] = null;
}
}
public void reset() {
mCurrentWindowLayout = null;
mIsDelayed = false;
mPendingCaptionEvents.clear();
for (int i = 0; i < CAPTION_WINDOWS_MAX; ++i) {
if (mCaptionWindowLayouts[i] != null) {
mCaptionWindowLayouts[i].removeFromCaptionView();
}
mCaptionWindowLayouts[i] = null;
}
mCCLayout.setVisibility(View.INVISIBLE);
mHandler.removeMessages(MSG_CAPTION_CLEAR);
}
private void setWindowAttr(Cea708CCParser.CaptionWindowAttr windowAttr) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.setWindowAttr(windowAttr);
}
}
private void defineWindow(Cea708CCParser.CaptionWindow window) {
if (window == null) {
return;
}
int windowId = window.id;
if (windowId < 0 || windowId >= mCaptionWindowLayouts.length) {
return;
}
CCWindowLayout windowLayout = mCaptionWindowLayouts[windowId];
if (windowLayout == null) {
windowLayout = new CCWindowLayout(mCCLayout.getContext());
}
windowLayout.initWindow(mCCLayout, window);
mCurrentWindowLayout = mCaptionWindowLayouts[windowId] = windowLayout;
}
// The job related caption commands
private void delay(int tenthsOfSeconds) {
if (tenthsOfSeconds < 0 || tenthsOfSeconds > 255) {
return;
}
mIsDelayed = true;
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_DELAY_CANCEL),
tenthsOfSeconds * TENTHS_OF_SECOND_IN_MILLIS);
}
private void delayCancel() {
mIsDelayed = false;
processPendingBuffer();
}
private void processPendingBuffer() {
for (Cea708CCParser.CaptionEvent event : mPendingCaptionEvents) {
processCaptionEvent(event);
}
mPendingCaptionEvents.clear();
}
// The implicit write caption commands
private void sendControlToCurrentWindow(char control) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.sendControl(control);
}
}
private void sendBufferToCurrentWindow(String buffer) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.sendBuffer(buffer);
mHandler.removeMessages(MSG_CAPTION_CLEAR);
mHandler.sendMessageDelayed(mHandler.obtainMessage(MSG_CAPTION_CLEAR),
CAPTION_CLEAR_INTERVAL_MS);
}
}
// The pen related caption commands
private void setPenAttr(Cea708CCParser.CaptionPenAttr attr) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.setPenAttr(attr);
}
}
private void setPenColor(Cea708CCParser.CaptionPenColor color) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.setPenColor(color);
}
}
private void setPenLocation(Cea708CCParser.CaptionPenLocation location) {
if (mCurrentWindowLayout != null) {
mCurrentWindowLayout.setPenLocation(location.row, location.column);
}
}
}
/**
* Layout which renders a caption window of CEA-708B. It contains a {@link TextView} that
* takes care of displaying the actual CC text.
*/
private class CCWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener {
private static final String TAG = "CCWindowLayout";
private static final float PROPORTION_PEN_SIZE_SMALL = .75f;
private static final float PROPORTION_PEN_SIZE_LARGE = 1.25f;
// The following values indicates the maximum cell number of a window.
private static final int ANCHOR_RELATIVE_POSITIONING_MAX = 99;
private static final int ANCHOR_VERTICAL_MAX = 74;
private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209;
private static final int MAX_COLUMN_COUNT_16_9 = 42;
// The following values indicates a gravity of a window.
private static final int ANCHOR_MODE_DIVIDER = 3;
private static final int ANCHOR_HORIZONTAL_MODE_LEFT = 0;
private static final int ANCHOR_HORIZONTAL_MODE_CENTER = 1;
private static final int ANCHOR_HORIZONTAL_MODE_RIGHT = 2;
private static final int ANCHOR_VERTICAL_MODE_TOP = 0;
private static final int ANCHOR_VERTICAL_MODE_CENTER = 1;
private static final int ANCHOR_VERTICAL_MODE_BOTTOM = 2;
private CCLayout mCCLayout;
private CCView mCCView;
private CaptionStyle mCaptionStyle;
private int mRowLimit = 0;
private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
private final List<CharacterStyle> mCharacterStyles = new ArrayList<>();
private int mCaptionWindowId;
private int mRow = -1;
private float mFontScale;
private float mTextSize;
private String mWidestChar;
private int mLastCaptionLayoutWidth;
private int mLastCaptionLayoutHeight;
CCWindowLayout(Context context) {
this(context, null);
}
CCWindowLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
CCWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// Add a subtitle view to the layout.
mCCView = new CCView(context);
LayoutParams params = new RelativeLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
addView(mCCView, params);
// Set the system wide CC preferences to the subtitle view.
CaptioningManager captioningManager =
(CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
mFontScale = captioningManager.getFontScale();
setCaptionStyle(captioningManager.getUserStyle());
mCCView.setText("");
updateWidestChar();
}
public void setCaptionStyle(CaptionStyle style) {
mCaptionStyle = style;
mCCView.setCaptionStyle(style);
}
public void setFontScale(float fontScale) {
mFontScale = fontScale;
updateTextSize();
}
public int getCaptionWindowId() {
return mCaptionWindowId;
}
public void setCaptionWindowId(int captionWindowId) {
mCaptionWindowId = captionWindowId;
}
public void clear() {
clearText();
hide();
}
public void show() {
setVisibility(View.VISIBLE);
requestLayout();
}
public void hide() {
setVisibility(View.INVISIBLE);
requestLayout();
}
public void setPenAttr(Cea708CCParser.CaptionPenAttr penAttr) {
mCharacterStyles.clear();
if (penAttr.italic) {
mCharacterStyles.add(new StyleSpan(Typeface.ITALIC));
}
if (penAttr.underline) {
mCharacterStyles.add(new UnderlineSpan());
}
switch (penAttr.penSize) {
case Cea708CCParser.CaptionPenAttr.PEN_SIZE_SMALL:
mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL));
break;
case Cea708CCParser.CaptionPenAttr.PEN_SIZE_LARGE:
mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE));
break;
}
switch (penAttr.penOffset) {
case Cea708CCParser.CaptionPenAttr.OFFSET_SUBSCRIPT:
mCharacterStyles.add(new SubscriptSpan());
break;
case Cea708CCParser.CaptionPenAttr.OFFSET_SUPERSCRIPT:
mCharacterStyles.add(new SuperscriptSpan());
break;
}
}
public void setPenColor(Cea708CCParser.CaptionPenColor penColor) {
// TODO: apply pen colors or skip this and use the style of system wide CC style
// as is.
}
public void setPenLocation(int row, int column) {
// TODO: change the location of pen based on row and column both.
if (mRow >= 0) {
for (int r = mRow; r < row; ++r) {
appendText("\n");
}
}
mRow = row;
}
public void setWindowAttr(Cea708CCParser.CaptionWindowAttr windowAttr) {
// TODO: apply window attrs or skip this and use the style of system wide CC style
// as is.
}
public void sendBuffer(String buffer) {
appendText(buffer);
}
public void sendControl(char control) {
// TODO: there are a bunch of ASCII-style control codes.
}
/**
* This method places the window on a given CaptionLayout along with the anchor of the
* window.
* <p>
* According to CEA-708B, the anchor id indicates the gravity of the window as the
* follows.
* For example, A value 7 of a anchor id says that a window is align with its parent
* bottom and is located at the center horizontally of its parent.
* </p>
* <h4>Anchor id and the gravity of a window</h4>
* <table>
* <tr>
* <th>GRAVITY</th>
* <th>LEFT</th>
* <th>CENTER_HORIZONTAL</th>
* <th>RIGHT</th>
* </tr>
* <tr>
* <th>TOP</th>
* <td>0</td>
* <td>1</td>
* <td>2</td>
* </tr>
* <tr>
* <th>CENTER_VERTICAL</th>
* <td>3</td>
* <td>4</td>
* <td>5</td>
* </tr>
* <tr>
* <th>BOTTOM</th>
* <td>6</td>
* <td>7</td>
* <td>8</td>
* </tr>
* </table>
* <p>
* In order to handle the gravity of a window, there are two steps.
* First, set the size of the window. Since the window will be positioned at
* ScaledLayout, the size factors are determined in a ratio.
* Second, set the gravity of the window. CaptionWindowLayout is inherited from
* RelativeLayout. Hence, we could set the gravity of its child view, SubtitleView.
* </p>
* <p>
* The gravity of the window is also related to its size. When it should be pushed to
* one of the end of the window, like LEFT, RIGHT, TOP or BOTTOM, the anchor point
* should be a boundary of the window. When it should be pushed
* in the horizontal/vertical center of its container, the horizontal/vertical center
* point of the window should be the same as the anchor point.
* </p>
*
* @param ccLayout a given CaptionLayout, which contains a safe title area.
* @param captionWindow a given CaptionWindow, which stores the construction info of the
* window.
*/
public void initWindow(CCLayout ccLayout, Cea708CCParser.CaptionWindow captionWindow) {
if (mCCLayout != ccLayout) {
if (mCCLayout != null) {
mCCLayout.removeOnLayoutChangeListener(this);
}
mCCLayout = ccLayout;
mCCLayout.addOnLayoutChangeListener(this);
updateWidestChar();
}
// Both anchor vertical and horizontal indicates the position cell number of
// the window.
float scaleRow = (float) captionWindow.anchorVertical
/ (captionWindow.relativePositioning
? ANCHOR_RELATIVE_POSITIONING_MAX : ANCHOR_VERTICAL_MAX);
// Assumes it has a wide aspect ratio track.
float scaleCol = (float) captionWindow.anchorHorizontal
/ (captionWindow.relativePositioning ? ANCHOR_RELATIVE_POSITIONING_MAX
: ANCHOR_HORIZONTAL_16_9_MAX);
// The range of scaleRow/Col need to be verified to be in [0, 1].
// Otherwise a RuntimeException will be raised in ScaledLayout.
if (scaleRow < 0 || scaleRow > 1) {
Log.i(TAG, "The vertical position of the anchor point should be at the "
+ "range of 0 and 1 but " + scaleRow);
scaleRow = Math.max(0, Math.min(scaleRow, 1));
}
if (scaleCol < 0 || scaleCol > 1) {
Log.i(TAG, "The horizontal position of the anchor point should be at the "
+ "range of 0 and 1 but " + scaleCol);
scaleCol = Math.max(0, Math.min(scaleCol, 1));
}
int gravity = Gravity.CENTER;
int horizontalMode = captionWindow.anchorId % ANCHOR_MODE_DIVIDER;
int verticalMode = captionWindow.anchorId / ANCHOR_MODE_DIVIDER;
float scaleStartRow = 0;
float scaleEndRow = 1;
float scaleStartCol = 0;
float scaleEndCol = 1;
switch (horizontalMode) {
case ANCHOR_HORIZONTAL_MODE_LEFT:
gravity = Gravity.LEFT;
mCCView.setAlignment(Alignment.ALIGN_NORMAL);
scaleStartCol = scaleCol;
break;
case ANCHOR_HORIZONTAL_MODE_CENTER:
float gap = Math.min(1 - scaleCol, scaleCol);
// Since all TV sets use left text alignment instead of center text
// alignment for this case, we follow the industry convention if possible.
int columnCount = captionWindow.columnCount + 1;
columnCount = Math.min(getScreenColumnCount(), columnCount);
StringBuilder widestTextBuilder = new StringBuilder();
for (int i = 0; i < columnCount; ++i) {
widestTextBuilder.append(mWidestChar);
}
Paint paint = new Paint();
paint.setTypeface(mCaptionStyle.getTypeface());
paint.setTextSize(mTextSize);
float maxWindowWidth = paint.measureText(widestTextBuilder.toString());
float halfMaxWidthScale = mCCLayout.getWidth() > 0
? maxWindowWidth / 2.0f / (mCCLayout.getWidth() * 0.8f) : 0.0f;
if (halfMaxWidthScale > 0f && halfMaxWidthScale < scaleCol) {
// Calculate the expected max window size based on the column count of
// the caption window multiplied by average alphabets char width,
// then align the left side of the window with the left side of
// the expected max window.
gravity = Gravity.LEFT;
mCCView.setAlignment(Alignment.ALIGN_NORMAL);
scaleStartCol = scaleCol - halfMaxWidthScale;
scaleEndCol = 1.0f;
} else {
// The gap will be the minimum distance value of the distances from both
// horizontal end points to the anchor point.
// If scaleCol <= 0.5, the range of scaleCol is
// [0, the anchor point * 2].
// If scaleCol > 0.5, the range of scaleCol is
// [(1 - the anchor point) * 2, 1].
// The anchor point is located at the horizontal center of the window in
// both cases.
gravity = Gravity.CENTER_HORIZONTAL;
mCCView.setAlignment(Alignment.ALIGN_CENTER);
scaleStartCol = scaleCol - gap;
scaleEndCol = scaleCol + gap;
}
break;
case ANCHOR_HORIZONTAL_MODE_RIGHT:
gravity = Gravity.RIGHT;
// TODO: Alignment.ALIGN_RIGHT is hidden. Implement setAlignment()
// in a different way.
// mCCView.setAlignment(Alignment.ALIGN_RIGHT);
scaleEndCol = scaleCol;
break;
}
switch (verticalMode) {
case ANCHOR_VERTICAL_MODE_TOP:
gravity |= Gravity.TOP;
scaleStartRow = scaleRow;
break;
case ANCHOR_VERTICAL_MODE_CENTER:
gravity |= Gravity.CENTER_VERTICAL;
// See the above comment.
float gap = Math.min(1 - scaleRow, scaleRow);
scaleStartRow = scaleRow - gap;
scaleEndRow = scaleRow + gap;
break;
case ANCHOR_VERTICAL_MODE_BOTTOM:
gravity |= Gravity.BOTTOM;
scaleEndRow = scaleRow;
break;
}
mCCLayout.addOrUpdateViewToSafeTitleArea(this,
mCCLayout.new ScaledLayoutParams(
scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol));
setCaptionWindowId(captionWindow.id);
setRowLimit(captionWindow.rowCount);
setGravity(gravity);
if (captionWindow.visible) {
show();
} else {
hide();
}
}
@Override
public void onLayoutChange(
View v, int left, int top, int right, int bottom, int oldLeft,
int oldTop, int oldRight, int oldBottom) {
int width = right - left;
int height = bottom - top;
if (width != mLastCaptionLayoutWidth || height != mLastCaptionLayoutHeight) {
mLastCaptionLayoutWidth = width;
mLastCaptionLayoutHeight = height;
updateTextSize();
}
}
private void updateWidestChar() {
Paint paint = new Paint();
paint.setTypeface(mCaptionStyle.getTypeface());
Charset latin1 = Charset.forName("ISO-8859-1");
float widestCharWidth = 0f;
for (int i = 0; i < 256; ++i) {
String ch = new String(new byte[]{(byte) i}, latin1);
float charWidth = paint.measureText(ch);
if (widestCharWidth < charWidth) {
widestCharWidth = charWidth;
mWidestChar = ch;
}
}
updateTextSize();
}
private void updateTextSize() {
if (mCCLayout == null) return;
// Calculate text size based on the max window size.
StringBuilder widestTextBuilder = new StringBuilder();
int screenColumnCount = getScreenColumnCount();
for (int i = 0; i < screenColumnCount; ++i) {
widestTextBuilder.append(mWidestChar);
}
String widestText = widestTextBuilder.toString();
Paint paint = new Paint();
paint.setTypeface(mCaptionStyle.getTypeface());
float startFontSize = 0f;
float endFontSize = 255f;
while (startFontSize < endFontSize) {
float testTextSize = (startFontSize + endFontSize) / 2f;
paint.setTextSize(testTextSize);
float width = paint.measureText(widestText);
if (mCCLayout.getWidth() * 0.8f > width) {
startFontSize = testTextSize + 0.01f;
} else {
endFontSize = testTextSize - 0.01f;
}
}
mTextSize = endFontSize * mFontScale;
mCCView.setTextSize(mTextSize);
}
private int getScreenColumnCount() {
// Assume it has a wide aspect ratio track.
return MAX_COLUMN_COUNT_16_9;
}
public void removeFromCaptionView() {
if (mCCLayout != null) {
mCCLayout.removeViewFromSafeTitleArea(this);
mCCLayout.removeOnLayoutChangeListener(this);
mCCLayout = null;
}
}
public void setText(String text) {
updateText(text, false);
}
public void appendText(String text) {
updateText(text, true);
}
public void clearText() {
mBuilder.clear();
mCCView.setText("");
}
private void updateText(String text, boolean appended) {
if (!appended) {
mBuilder.clear();
}
if (text != null && text.length() > 0) {
int length = mBuilder.length();
mBuilder.append(text);
for (CharacterStyle characterStyle : mCharacterStyles) {
mBuilder.setSpan(characterStyle, length, mBuilder.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
String[] lines = TextUtils.split(mBuilder.toString(), "\n");
// Truncate text not to exceed the row limit.
// Plus one here since the range of the rows is [0, mRowLimit].
String truncatedText = TextUtils.join("\n", Arrays.copyOfRange(
lines, Math.max(0, lines.length - (mRowLimit + 1)), lines.length));
mBuilder.delete(0, mBuilder.length() - truncatedText.length());
// Trim the buffer first then set text to CCView.
int start = 0, last = mBuilder.length() - 1;
int end = last;
while ((start <= end) && (mBuilder.charAt(start) <= ' ')) {
++start;
}
while ((end >= start) && (mBuilder.charAt(end) <= ' ')) {
--end;
}
if (start == 0 && end == last) {
mCCView.setText(mBuilder);
} else {
SpannableStringBuilder trim = new SpannableStringBuilder();
trim.append(mBuilder);
if (end < last) {
trim.delete(end + 1, last + 1);
}
if (start > 0) {
trim.delete(0, start);
}
mCCView.setText(trim);
}
}
public void setRowLimit(int rowLimit) {
if (rowLimit < 0) {
throw new IllegalArgumentException("A rowLimit should have a positive number");
}
mRowLimit = rowLimit;
}
}
class CCView extends SubtitleView {
CCView(Context context) {
this(context, null);
}
CCView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
CCView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
void setCaptionStyle(CaptionStyle style) {
if (Build.VERSION.SDK_INT >= 21) {
if (style.hasForegroundColor()) {
setForegroundColor(style.foregroundColor);
}
if (style.hasBackgroundColor()) {
setBackgroundColor(style.backgroundColor);
}
if (style.hasEdgeType()) {
setEdgeType(style.edgeType);
}
if (style.hasEdgeColor()) {
setEdgeColor(style.edgeColor);
}
}
setTypeface(style.getTypeface());
}
}
}
}