blob: 26b924938a0892b8e684ed91e3671ca614964998 [file] [log] [blame]
/*
* Copyright (C) 2015 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 com.android.usbtuner.cc;
import android.content.Context;
import android.graphics.Paint;
import android.graphics.Typeface;
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.view.accessibility.CaptioningManager.CaptioningChangeListener;
import android.widget.RelativeLayout;
import com.google.android.exoplayer.text.CaptionStyleCompat;
import com.google.android.exoplayer.text.SubtitleView;
import com.android.usbtuner.data.Cea708Data.CaptionPenAttr;
import com.android.usbtuner.data.Cea708Data.CaptionPenColor;
import com.android.usbtuner.data.Cea708Data.CaptionWindow;
import com.android.usbtuner.data.Cea708Data.CaptionWindowAttr;
import com.android.usbtuner.layout.ScaledLayout;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Layout which renders a caption window of CEA-708B. It contains a {@link SubtitleView} that
* takes care of displaying the actual cc text.
*/
public class CaptionWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener {
private static final String TAG = "CaptionWindowLayout";
private static final boolean DEBUG = false;
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_4_3_MAX = 159;
private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209;
// 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 static final int US_MAX_COLUMN_COUNT_16_9 = 42;
private static final int US_MAX_COLUMN_COUNT_4_3 = 32;
private static final int KR_MAX_COLUMN_COUNT_16_9 = 52;
private static final int KR_MAX_COLUMN_COUNT_4_3 = 40;
private static final String KOR_ALPHABET =
new String("\uAC00".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
private static final float WIDE_SCREEN_ASPECT_RATIO_THRESHOLD = 1.6f;
private CaptionLayout mCaptionLayout;
private CaptionStyleCompat mCaptionStyleCompat;
// TODO: Replace SubtitleView to {@link com.google.android.exoplayer.text.SubtitleLayout}.
private final SubtitleView mSubtitleView;
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;
private class SystemWideCaptioningChangeListener extends CaptioningChangeListener {
@Override
public void onUserStyleChanged(CaptionStyle userStyle) {
mCaptionStyleCompat = CaptionStyleCompat.createFromCaptionStyle(userStyle);
mSubtitleView.setStyle(mCaptionStyleCompat);
updateWidestChar();
}
@Override
public void onFontScaleChanged(float fontScale) {
mFontScale = fontScale;
updateTextSize();
}
}
public CaptionWindowLayout(Context context) {
this(context, null);
}
public CaptionWindowLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
// Add a subtitle view to the layout.
mSubtitleView = new SubtitleView(context);
LayoutParams params = new RelativeLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
addView(mSubtitleView, params);
// Set the system wide cc preferences to the subtitle view.
CaptioningManager captioningManager =
(CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE);
mFontScale = captioningManager.getFontScale();
mCaptionStyleCompat =
CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle());
mSubtitleView.setStyle(mCaptionStyleCompat);
mSubtitleView.setText("");
captioningManager.addCaptioningChangeListener(new SystemWideCaptioningChangeListener());
updateWidestChar();
}
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(CaptionPenAttr penAttr) {
mCharacterStyles.clear();
if (penAttr.italic) {
mCharacterStyles.add(new StyleSpan(Typeface.ITALIC));
}
if (penAttr.underline) {
mCharacterStyles.add(new UnderlineSpan());
}
switch (penAttr.penSize) {
case CaptionPenAttr.PEN_SIZE_SMALL:
mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL));
break;
case CaptionPenAttr.PEN_SIZE_LARGE:
mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE));
break;
}
switch (penAttr.penOffset) {
case CaptionPenAttr.OFFSET_SUBSCRIPT:
mCharacterStyles.add(new SubscriptSpan());
break;
case CaptionPenAttr.OFFSET_SUPERSCRIPT:
mCharacterStyles.add(new SuperscriptSpan());
break;
}
}
public void setPenColor(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(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 {@link ScaledLayout}, the size factors are
* determined in a ratio. Second, set the gravity of the window. {@link CaptionWindowLayout} is
* inherited from {@link RelativeLayout}. Hence, we could set the gravity of its child view,
* {@link SubtitleView}.
* </p>
* <p>
* The gravity of the window is also related to its size. When it should be pushed to a 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 captionLayout a given {@link CaptionLayout}, which contains a safe title area
* @param captionWindow a given {@link CaptionWindow}, which stores the construction info of the
* window
*/
public void initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow) {
if (DEBUG) {
Log.d(TAG, "initWindow with "
+ (captionLayout != null ? captionLayout.getCaptionTrack() : null));
}
if (mCaptionLayout != captionLayout) {
if (mCaptionLayout != null) {
mCaptionLayout.removeOnLayoutChangeListener(this);
}
mCaptionLayout = captionLayout;
mCaptionLayout.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);
float scaleCol = (float) captionWindow.anchorHorizontal /
(captionWindow.relativePositioning ? ANCHOR_RELATIVE_POSITIONING_MAX
: (isWideAspectRatio()
? ANCHOR_HORIZONTAL_16_9_MAX : ANCHOR_HORIZONTAL_4_3_MAX));
// The range of scaleRow/Col need to be verified to be in [0, 1].
// Otherwise a {@link RuntimeException} will be raised in {@link 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;
mSubtitleView.setTextAlignment(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;
if (isKoreanLanguageTrack()) {
columnCount /= 2;
}
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(mCaptionStyleCompat.typeface);
paint.setTextSize(mTextSize);
float maxWindowWidth = paint.measureText(widestTextBuilder.toString());
float halfMaxWidthScale = mCaptionLayout.getWidth() > 0
? maxWindowWidth / 2.0f / (mCaptionLayout.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;
mSubtitleView.setTextAlignment(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;
mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER);
scaleStartCol = scaleCol - gap;
scaleEndCol = scaleCol + gap;
}
break;
case ANCHOR_HORIZONTAL_MODE_RIGHT:
gravity = Gravity.RIGHT;
mSubtitleView.setTextAlignment(Alignment.ALIGN_OPPOSITE);
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;
}
mCaptionLayout.addOrUpdateViewToSafeTitleArea(this, new ScaledLayout
.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 boolean isKoreanLanguageTrack() {
return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null
&& mCaptionLayout.getCaptionTrack().language != null
&& "KOR".compareToIgnoreCase(mCaptionLayout.getCaptionTrack().language) == 0;
}
private boolean isWideAspectRatio() {
return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null
&& mCaptionLayout.getCaptionTrack().wideAspectRatio;
}
private void updateWidestChar() {
if (isKoreanLanguageTrack()) {
mWidestChar = KOR_ALPHABET;
} else {
Paint paint = new Paint();
paint.setTypeface(mCaptionStyleCompat.typeface);
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 (mCaptionLayout == 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(mCaptionStyleCompat.typeface);
float startFontSize = 0f;
float endFontSize = 255f;
while (startFontSize < endFontSize) {
float testTextSize = (startFontSize + endFontSize) / 2f;
paint.setTextSize(testTextSize);
float width = paint.measureText(widestText);
if (mCaptionLayout.getWidth() * 0.8f > width) {
startFontSize = testTextSize + 0.01f;
} else {
endFontSize = testTextSize - 0.01f;
}
}
mTextSize = endFontSize * mFontScale;
mSubtitleView.setTextSize(mTextSize);
}
private int getScreenColumnCount() {
float screenAspectRatio = (float) mCaptionLayout.getWidth() / mCaptionLayout.getHeight();
boolean isWideAspectRationScreen = screenAspectRatio > WIDE_SCREEN_ASPECT_RATIO_THRESHOLD;
if (isKoreanLanguageTrack()) {
// Each korean character consumes two slots.
if (isWideAspectRationScreen || isWideAspectRatio()) {
return KR_MAX_COLUMN_COUNT_16_9 / 2;
} else {
return KR_MAX_COLUMN_COUNT_4_3 / 2;
}
} else {
if (isWideAspectRationScreen || isWideAspectRatio()) {
return US_MAX_COLUMN_COUNT_16_9;
} else {
return US_MAX_COLUMN_COUNT_4_3;
}
}
}
public void removeFromCaptionView() {
if (mCaptionLayout != null) {
mCaptionLayout.removeViewFromSafeTitleArea(this);
mCaptionLayout.removeOnLayoutChangeListener(this);
mCaptionLayout = null;
}
}
public void setText(String text) {
updateText(text, false);
}
public void appendText(String text) {
updateText(text, true);
}
public void clearText() {
mBuilder.clear();
mSubtitleView.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 {@link SubtitleView}.
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) {
mSubtitleView.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);
}
mSubtitleView.setText(trim);
}
}
public void setRowLimit(int rowLimit) {
if (rowLimit < 0) {
throw new IllegalArgumentException("A rowLimit should have a positive number");
}
mRowLimit = rowLimit;
}
}