1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.usbtuner.cc; 18 19 import android.content.Context; 20 import android.graphics.Paint; 21 import android.graphics.Typeface; 22 import android.text.Layout.Alignment; 23 import android.text.SpannableStringBuilder; 24 import android.text.Spanned; 25 import android.text.TextUtils; 26 import android.text.style.CharacterStyle; 27 import android.text.style.RelativeSizeSpan; 28 import android.text.style.StyleSpan; 29 import android.text.style.SubscriptSpan; 30 import android.text.style.SuperscriptSpan; 31 import android.text.style.UnderlineSpan; 32 import android.util.AttributeSet; 33 import android.util.Log; 34 import android.view.Gravity; 35 import android.view.View; 36 import android.view.ViewGroup; 37 import android.view.accessibility.CaptioningManager; 38 import android.view.accessibility.CaptioningManager.CaptionStyle; 39 import android.view.accessibility.CaptioningManager.CaptioningChangeListener; 40 import android.widget.RelativeLayout; 41 42 import com.google.android.exoplayer.text.CaptionStyleCompat; 43 import com.google.android.exoplayer.text.SubtitleView; 44 import com.android.usbtuner.data.Cea708Data.CaptionPenAttr; 45 import com.android.usbtuner.data.Cea708Data.CaptionPenColor; 46 import com.android.usbtuner.data.Cea708Data.CaptionWindow; 47 import com.android.usbtuner.data.Cea708Data.CaptionWindowAttr; 48 import com.android.usbtuner.layout.ScaledLayout; 49 50 import java.nio.charset.Charset; 51 import java.nio.charset.StandardCharsets; 52 import java.util.ArrayList; 53 import java.util.Arrays; 54 import java.util.List; 55 56 /** 57 * Layout which renders a caption window of CEA-708B. It contains a {@link SubtitleView} that 58 * takes care of displaying the actual cc text. 59 */ 60 public class CaptionWindowLayout extends RelativeLayout implements View.OnLayoutChangeListener { 61 private static final String TAG = "CaptionWindowLayout"; 62 private static final boolean DEBUG = false; 63 64 private static final float PROPORTION_PEN_SIZE_SMALL = .75f; 65 private static final float PROPORTION_PEN_SIZE_LARGE = 1.25f; 66 67 // The following values indicates the maximum cell number of a window. 68 private static final int ANCHOR_RELATIVE_POSITIONING_MAX = 99; 69 private static final int ANCHOR_VERTICAL_MAX = 74; 70 private static final int ANCHOR_HORIZONTAL_4_3_MAX = 159; 71 private static final int ANCHOR_HORIZONTAL_16_9_MAX = 209; 72 73 // The following values indicates a gravity of a window. 74 private static final int ANCHOR_MODE_DIVIDER = 3; 75 private static final int ANCHOR_HORIZONTAL_MODE_LEFT = 0; 76 private static final int ANCHOR_HORIZONTAL_MODE_CENTER = 1; 77 private static final int ANCHOR_HORIZONTAL_MODE_RIGHT = 2; 78 private static final int ANCHOR_VERTICAL_MODE_TOP = 0; 79 private static final int ANCHOR_VERTICAL_MODE_CENTER = 1; 80 private static final int ANCHOR_VERTICAL_MODE_BOTTOM = 2; 81 82 private static final int US_MAX_COLUMN_COUNT_16_9 = 42; 83 private static final int US_MAX_COLUMN_COUNT_4_3 = 32; 84 private static final int KR_MAX_COLUMN_COUNT_16_9 = 52; 85 private static final int KR_MAX_COLUMN_COUNT_4_3 = 40; 86 87 private static final String KOR_ALPHABET = 88 new String("\uAC00".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8); 89 private static final float WIDE_SCREEN_ASPECT_RATIO_THRESHOLD = 1.6f; 90 91 private CaptionLayout mCaptionLayout; 92 private CaptionStyleCompat mCaptionStyleCompat; 93 94 // TODO: Replace SubtitleView to {@link com.google.android.exoplayer.text.SubtitleLayout}. 95 private final SubtitleView mSubtitleView; 96 private int mRowLimit = 0; 97 private final SpannableStringBuilder mBuilder = new SpannableStringBuilder(); 98 private final List<CharacterStyle> mCharacterStyles = new ArrayList<>(); 99 private int mCaptionWindowId; 100 private int mRow = -1; 101 private float mFontScale; 102 private float mTextSize; 103 private String mWidestChar; 104 private int mLastCaptionLayoutWidth; 105 private int mLastCaptionLayoutHeight; 106 107 private class SystemWideCaptioningChangeListener extends CaptioningChangeListener { 108 @Override onUserStyleChanged(CaptionStyle userStyle)109 public void onUserStyleChanged(CaptionStyle userStyle) { 110 mCaptionStyleCompat = CaptionStyleCompat.createFromCaptionStyle(userStyle); 111 mSubtitleView.setStyle(mCaptionStyleCompat); 112 updateWidestChar(); 113 } 114 115 @Override onFontScaleChanged(float fontScale)116 public void onFontScaleChanged(float fontScale) { 117 mFontScale = fontScale; 118 updateTextSize(); 119 } 120 } 121 CaptionWindowLayout(Context context)122 public CaptionWindowLayout(Context context) { 123 this(context, null); 124 } 125 CaptionWindowLayout(Context context, AttributeSet attrs)126 public CaptionWindowLayout(Context context, AttributeSet attrs) { 127 this(context, attrs, 0); 128 } 129 CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr)130 public CaptionWindowLayout(Context context, AttributeSet attrs, int defStyleAttr) { 131 super(context, attrs, defStyleAttr); 132 133 // Add a subtitle view to the layout. 134 mSubtitleView = new SubtitleView(context); 135 LayoutParams params = new RelativeLayout.LayoutParams( 136 ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); 137 addView(mSubtitleView, params); 138 139 // Set the system wide cc preferences to the subtitle view. 140 CaptioningManager captioningManager = 141 (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); 142 mFontScale = captioningManager.getFontScale(); 143 mCaptionStyleCompat = 144 CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); 145 mSubtitleView.setStyle(mCaptionStyleCompat); 146 mSubtitleView.setText(""); 147 captioningManager.addCaptioningChangeListener(new SystemWideCaptioningChangeListener()); 148 updateWidestChar(); 149 } 150 getCaptionWindowId()151 public int getCaptionWindowId() { 152 return mCaptionWindowId; 153 } 154 setCaptionWindowId(int captionWindowId)155 public void setCaptionWindowId(int captionWindowId) { 156 mCaptionWindowId = captionWindowId; 157 } 158 clear()159 public void clear() { 160 clearText(); 161 hide(); 162 } 163 show()164 public void show() { 165 setVisibility(View.VISIBLE); 166 requestLayout(); 167 } 168 hide()169 public void hide() { 170 setVisibility(View.INVISIBLE); 171 requestLayout(); 172 } 173 setPenAttr(CaptionPenAttr penAttr)174 public void setPenAttr(CaptionPenAttr penAttr) { 175 mCharacterStyles.clear(); 176 if (penAttr.italic) { 177 mCharacterStyles.add(new StyleSpan(Typeface.ITALIC)); 178 } 179 if (penAttr.underline) { 180 mCharacterStyles.add(new UnderlineSpan()); 181 } 182 switch (penAttr.penSize) { 183 case CaptionPenAttr.PEN_SIZE_SMALL: 184 mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_SMALL)); 185 break; 186 case CaptionPenAttr.PEN_SIZE_LARGE: 187 mCharacterStyles.add(new RelativeSizeSpan(PROPORTION_PEN_SIZE_LARGE)); 188 break; 189 } 190 switch (penAttr.penOffset) { 191 case CaptionPenAttr.OFFSET_SUBSCRIPT: 192 mCharacterStyles.add(new SubscriptSpan()); 193 break; 194 case CaptionPenAttr.OFFSET_SUPERSCRIPT: 195 mCharacterStyles.add(new SuperscriptSpan()); 196 break; 197 } 198 } 199 setPenColor(CaptionPenColor penColor)200 public void setPenColor(CaptionPenColor penColor) { 201 // TODO: apply pen colors or skip this and use the style of system wide cc style as is. 202 } 203 setPenLocation(int row, int column)204 public void setPenLocation(int row, int column) { 205 // TODO: change the location of pen based on row and column both. 206 if (mRow >= 0) { 207 for (int r = mRow; r < row; ++r) { 208 appendText("\n"); 209 } 210 } 211 mRow = row; 212 } 213 setWindowAttr(CaptionWindowAttr windowAttr)214 public void setWindowAttr(CaptionWindowAttr windowAttr) { 215 // TODO: apply window attrs or skip this and use the style of system wide cc style as is. 216 } 217 sendBuffer(String buffer)218 public void sendBuffer(String buffer) { 219 appendText(buffer); 220 } 221 sendControl(char control)222 public void sendControl(char control) { 223 // TODO: there are a bunch of ASCII-style control codes. 224 } 225 226 /** 227 * This method places the window on a given CaptionLayout along with the anchor of the window. 228 * <p> 229 * According to CEA-708B, the anchor id indicates the gravity of the window as the follows. 230 * For example, A value 7 of a anchor id says that a window is align with its parent bottom and 231 * is located at the center horizontally of its parent. 232 * </p> 233 * <h4>Anchor id and the gravity of a window</h4> 234 * <table> 235 * <tr> 236 * <th>GRAVITY</th> 237 * <th>LEFT</th> 238 * <th>CENTER_HORIZONTAL</th> 239 * <th>RIGHT</th> 240 * </tr> 241 * <tr> 242 * <th>TOP</th> 243 * <td>0</td> 244 * <td>1</td> 245 * <td>2</td> 246 * </tr> 247 * <tr> 248 * <th>CENTER_VERTICAL</th> 249 * <td>3</td> 250 * <td>4</td> 251 * <td>5</td> 252 * </tr> 253 * <tr> 254 * <th>BOTTOM</th> 255 * <td>6</td> 256 * <td>7</td> 257 * <td>8</td> 258 * </tr> 259 * </table> 260 * <p> 261 * In order to handle the gravity of a window, there are two steps. First, set the size of the 262 * window. Since the window will be positioned at {@link ScaledLayout}, the size factors are 263 * determined in a ratio. Second, set the gravity of the window. {@link CaptionWindowLayout} is 264 * inherited from {@link RelativeLayout}. Hence, we could set the gravity of its child view, 265 * {@link SubtitleView}. 266 * </p> 267 * <p> 268 * The gravity of the window is also related to its size. When it should be pushed to a one of 269 * the end of the window, like LEFT, RIGHT, TOP or BOTTOM, the anchor point should be a boundary 270 * of the window. When it should be pushed in the horizontal/vertical center of its container, 271 * the horizontal/vertical center point of the window should be the same as the anchor point. 272 * </p> 273 * 274 * @param captionLayout a given {@link CaptionLayout}, which contains a safe title area 275 * @param captionWindow a given {@link CaptionWindow}, which stores the construction info of the 276 * window 277 */ initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow)278 public void initWindow(CaptionLayout captionLayout, CaptionWindow captionWindow) { 279 if (DEBUG) { 280 Log.d(TAG, "initWindow with " 281 + (captionLayout != null ? captionLayout.getCaptionTrack() : null)); 282 } 283 if (mCaptionLayout != captionLayout) { 284 if (mCaptionLayout != null) { 285 mCaptionLayout.removeOnLayoutChangeListener(this); 286 } 287 mCaptionLayout = captionLayout; 288 mCaptionLayout.addOnLayoutChangeListener(this); 289 updateWidestChar(); 290 } 291 292 // Both anchor vertical and horizontal indicates the position cell number of the window. 293 float scaleRow = (float) captionWindow.anchorVertical / (captionWindow.relativePositioning 294 ? ANCHOR_RELATIVE_POSITIONING_MAX : ANCHOR_VERTICAL_MAX); 295 float scaleCol = (float) captionWindow.anchorHorizontal / 296 (captionWindow.relativePositioning ? ANCHOR_RELATIVE_POSITIONING_MAX 297 : (isWideAspectRatio() 298 ? ANCHOR_HORIZONTAL_16_9_MAX : ANCHOR_HORIZONTAL_4_3_MAX)); 299 300 // The range of scaleRow/Col need to be verified to be in [0, 1]. 301 // Otherwise a {@link RuntimeException} will be raised in {@link ScaledLayout}. 302 if (scaleRow < 0 || scaleRow > 1) { 303 Log.i(TAG, "The vertical position of the anchor point should be at the range of 0 and 1" 304 + " but " + scaleRow); 305 scaleRow = Math.max(0, Math.min(scaleRow, 1)); 306 } 307 if (scaleCol < 0 || scaleCol > 1) { 308 Log.i(TAG, "The horizontal position of the anchor point should be at the range of 0 and" 309 + " 1 but " + scaleCol); 310 scaleCol = Math.max(0, Math.min(scaleCol, 1)); 311 } 312 int gravity = Gravity.CENTER; 313 int horizontalMode = captionWindow.anchorId % ANCHOR_MODE_DIVIDER; 314 int verticalMode = captionWindow.anchorId / ANCHOR_MODE_DIVIDER; 315 float scaleStartRow = 0; 316 float scaleEndRow = 1; 317 float scaleStartCol = 0; 318 float scaleEndCol = 1; 319 switch (horizontalMode) { 320 case ANCHOR_HORIZONTAL_MODE_LEFT: 321 gravity = Gravity.LEFT; 322 mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL); 323 scaleStartCol = scaleCol; 324 break; 325 case ANCHOR_HORIZONTAL_MODE_CENTER: 326 float gap = Math.min(1 - scaleCol, scaleCol); 327 328 // Since all TV sets use left text alignment instead of center text alignment 329 // for this case, we follow the industry convention if possible. 330 int columnCount = captionWindow.columnCount + 1; 331 if (isKoreanLanguageTrack()) { 332 columnCount /= 2; 333 } 334 columnCount = Math.min(getScreenColumnCount(), columnCount); 335 StringBuilder widestTextBuilder = new StringBuilder(); 336 for (int i = 0; i < columnCount; ++i) { 337 widestTextBuilder.append(mWidestChar); 338 } 339 Paint paint = new Paint(); 340 paint.setTypeface(mCaptionStyleCompat.typeface); 341 paint.setTextSize(mTextSize); 342 float maxWindowWidth = paint.measureText(widestTextBuilder.toString()); 343 float halfMaxWidthScale = mCaptionLayout.getWidth() > 0 344 ? maxWindowWidth / 2.0f / (mCaptionLayout.getWidth() * 0.8f) : 0.0f; 345 if (halfMaxWidthScale > 0f && halfMaxWidthScale < scaleCol) { 346 // Calculate the expected max window size based on the column count of the 347 // caption window multiplied by average alphabets char width, then align the 348 // left side of the window with the left side of the expected max window. 349 gravity = Gravity.LEFT; 350 mSubtitleView.setTextAlignment(Alignment.ALIGN_NORMAL); 351 scaleStartCol = scaleCol - halfMaxWidthScale; 352 scaleEndCol = 1.0f; 353 } else { 354 // The gap will be the minimum distance value of the distances from both 355 // horizontal end points to the anchor point. 356 // If scaleCol <= 0.5, the range of scaleCol is [0, the anchor point * 2]. 357 // If scaleCol > 0.5, the range of scaleCol is [(1 - the anchor point) * 2, 1]. 358 // The anchor point is located at the horizontal center of the window in both 359 // cases. 360 gravity = Gravity.CENTER_HORIZONTAL; 361 mSubtitleView.setTextAlignment(Alignment.ALIGN_CENTER); 362 scaleStartCol = scaleCol - gap; 363 scaleEndCol = scaleCol + gap; 364 } 365 break; 366 case ANCHOR_HORIZONTAL_MODE_RIGHT: 367 gravity = Gravity.RIGHT; 368 mSubtitleView.setTextAlignment(Alignment.ALIGN_OPPOSITE); 369 scaleEndCol = scaleCol; 370 break; 371 } 372 switch (verticalMode) { 373 case ANCHOR_VERTICAL_MODE_TOP: 374 gravity |= Gravity.TOP; 375 scaleStartRow = scaleRow; 376 break; 377 case ANCHOR_VERTICAL_MODE_CENTER: 378 gravity |= Gravity.CENTER_VERTICAL; 379 380 // See the above comment. 381 float gap = Math.min(1 - scaleRow, scaleRow); 382 scaleStartRow = scaleRow - gap; 383 scaleEndRow = scaleRow + gap; 384 break; 385 case ANCHOR_VERTICAL_MODE_BOTTOM: 386 gravity |= Gravity.BOTTOM; 387 scaleEndRow = scaleRow; 388 break; 389 } 390 mCaptionLayout.addOrUpdateViewToSafeTitleArea(this, new ScaledLayout 391 .ScaledLayoutParams(scaleStartRow, scaleEndRow, scaleStartCol, scaleEndCol)); 392 setCaptionWindowId(captionWindow.id); 393 setRowLimit(captionWindow.rowCount); 394 setGravity(gravity); 395 if (captionWindow.visible) { 396 show(); 397 } else { 398 hide(); 399 } 400 } 401 402 @Override onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom)403 public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, 404 int oldTop, int oldRight, int oldBottom) { 405 int width = right - left; 406 int height = bottom - top; 407 if (width != mLastCaptionLayoutWidth || height != mLastCaptionLayoutHeight) { 408 mLastCaptionLayoutWidth = width; 409 mLastCaptionLayoutHeight = height; 410 updateTextSize(); 411 } 412 } 413 isKoreanLanguageTrack()414 private boolean isKoreanLanguageTrack() { 415 return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null 416 && mCaptionLayout.getCaptionTrack().language != null 417 && "KOR".compareToIgnoreCase(mCaptionLayout.getCaptionTrack().language) == 0; 418 } 419 isWideAspectRatio()420 private boolean isWideAspectRatio() { 421 return mCaptionLayout != null && mCaptionLayout.getCaptionTrack() != null 422 && mCaptionLayout.getCaptionTrack().wideAspectRatio; 423 } 424 updateWidestChar()425 private void updateWidestChar() { 426 if (isKoreanLanguageTrack()) { 427 mWidestChar = KOR_ALPHABET; 428 } else { 429 Paint paint = new Paint(); 430 paint.setTypeface(mCaptionStyleCompat.typeface); 431 Charset latin1 = Charset.forName("ISO-8859-1"); 432 float widestCharWidth = 0f; 433 for (int i = 0; i < 256; ++i) { 434 String ch = new String(new byte[]{(byte) i}, latin1); 435 float charWidth = paint.measureText(ch); 436 if (widestCharWidth < charWidth) { 437 widestCharWidth = charWidth; 438 mWidestChar = ch; 439 } 440 } 441 } 442 updateTextSize(); 443 } 444 updateTextSize()445 private void updateTextSize() { 446 if (mCaptionLayout == null) return; 447 448 // Calculate text size based on the max window size. 449 StringBuilder widestTextBuilder = new StringBuilder(); 450 int screenColumnCount = getScreenColumnCount(); 451 for (int i = 0; i < screenColumnCount; ++i) { 452 widestTextBuilder.append(mWidestChar); 453 } 454 String widestText = widestTextBuilder.toString(); 455 Paint paint = new Paint(); 456 paint.setTypeface(mCaptionStyleCompat.typeface); 457 float startFontSize = 0f; 458 float endFontSize = 255f; 459 while (startFontSize < endFontSize) { 460 float testTextSize = (startFontSize + endFontSize) / 2f; 461 paint.setTextSize(testTextSize); 462 float width = paint.measureText(widestText); 463 if (mCaptionLayout.getWidth() * 0.8f > width) { 464 startFontSize = testTextSize + 0.01f; 465 } else { 466 endFontSize = testTextSize - 0.01f; 467 } 468 } 469 mTextSize = endFontSize * mFontScale; 470 mSubtitleView.setTextSize(mTextSize); 471 } 472 getScreenColumnCount()473 private int getScreenColumnCount() { 474 float screenAspectRatio = (float) mCaptionLayout.getWidth() / mCaptionLayout.getHeight(); 475 boolean isWideAspectRationScreen = screenAspectRatio > WIDE_SCREEN_ASPECT_RATIO_THRESHOLD; 476 if (isKoreanLanguageTrack()) { 477 // Each korean character consumes two slots. 478 if (isWideAspectRationScreen || isWideAspectRatio()) { 479 return KR_MAX_COLUMN_COUNT_16_9 / 2; 480 } else { 481 return KR_MAX_COLUMN_COUNT_4_3 / 2; 482 } 483 } else { 484 if (isWideAspectRationScreen || isWideAspectRatio()) { 485 return US_MAX_COLUMN_COUNT_16_9; 486 } else { 487 return US_MAX_COLUMN_COUNT_4_3; 488 } 489 } 490 } 491 removeFromCaptionView()492 public void removeFromCaptionView() { 493 if (mCaptionLayout != null) { 494 mCaptionLayout.removeViewFromSafeTitleArea(this); 495 mCaptionLayout.removeOnLayoutChangeListener(this); 496 mCaptionLayout = null; 497 } 498 } 499 setText(String text)500 public void setText(String text) { 501 updateText(text, false); 502 } 503 appendText(String text)504 public void appendText(String text) { 505 updateText(text, true); 506 } 507 clearText()508 public void clearText() { 509 mBuilder.clear(); 510 mSubtitleView.setText(""); 511 } 512 updateText(String text, boolean appended)513 private void updateText(String text, boolean appended) { 514 if (!appended) { 515 mBuilder.clear(); 516 } 517 if (text != null && text.length() > 0) { 518 int length = mBuilder.length(); 519 mBuilder.append(text); 520 for (CharacterStyle characterStyle : mCharacterStyles) { 521 mBuilder.setSpan(characterStyle, length, mBuilder.length(), 522 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); 523 } 524 } 525 String[] lines = TextUtils.split(mBuilder.toString(), "\n"); 526 527 // Truncate text not to exceed the row limit. 528 // Plus one here since the range of the rows is [0, mRowLimit]. 529 String truncatedText = TextUtils.join("\n", Arrays.copyOfRange( 530 lines, Math.max(0, lines.length - (mRowLimit + 1)), lines.length)); 531 mBuilder.delete(0, mBuilder.length() - truncatedText.length()); 532 533 // Trim the buffer first then set text to {@link SubtitleView}. 534 int start = 0, last = mBuilder.length() - 1; 535 int end = last; 536 while ((start <= end) && (mBuilder.charAt(start) <= ' ')) { 537 ++start; 538 } 539 while ((end >= start) && (mBuilder.charAt(end) <= ' ')) { 540 --end; 541 } 542 if (start == 0 && end == last) { 543 mSubtitleView.setText(mBuilder); 544 } else { 545 SpannableStringBuilder trim = new SpannableStringBuilder(); 546 trim.append(mBuilder); 547 if (end < last) { 548 trim.delete(end + 1, last + 1); 549 } 550 if (start > 0) { 551 trim.delete(0, start); 552 } 553 mSubtitleView.setText(trim); 554 } 555 } 556 setRowLimit(int rowLimit)557 public void setRowLimit(int rowLimit) { 558 if (rowLimit < 0) { 559 throw new IllegalArgumentException("A rowLimit should have a positive number"); 560 } 561 mRowLimit = rowLimit; 562 } 563 } 564