1/* 2 * Copyright (C) 2013 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 17package android.media; 18 19import android.content.Context; 20import android.text.Layout.Alignment; 21import android.text.SpannableStringBuilder; 22import android.util.ArrayMap; 23import android.util.AttributeSet; 24import android.util.Log; 25import android.view.Gravity; 26import android.view.View; 27import android.view.ViewGroup; 28import android.view.accessibility.CaptioningManager; 29import android.view.accessibility.CaptioningManager.CaptionStyle; 30import android.view.accessibility.CaptioningManager.CaptioningChangeListener; 31import android.widget.LinearLayout; 32 33import com.android.internal.widget.SubtitleView; 34 35import java.util.ArrayList; 36import java.util.Arrays; 37import java.util.HashMap; 38import java.util.Map; 39import java.util.Vector; 40 41/** @hide */ 42public class WebVttRenderer extends SubtitleController.Renderer { 43 private final Context mContext; 44 45 private WebVttRenderingWidget mRenderingWidget; 46 47 public WebVttRenderer(Context context) { 48 mContext = context; 49 } 50 51 @Override 52 public boolean supports(MediaFormat format) { 53 if (format.containsKey(MediaFormat.KEY_MIME)) { 54 return format.getString(MediaFormat.KEY_MIME).equals("text/vtt"); 55 } 56 return false; 57 } 58 59 @Override 60 public SubtitleTrack createTrack(MediaFormat format) { 61 if (mRenderingWidget == null) { 62 mRenderingWidget = new WebVttRenderingWidget(mContext); 63 } 64 65 return new WebVttTrack(mRenderingWidget, format); 66 } 67} 68 69/** @hide */ 70class TextTrackCueSpan { 71 long mTimestampMs; 72 boolean mEnabled; 73 String mText; 74 TextTrackCueSpan(String text, long timestamp) { 75 mTimestampMs = timestamp; 76 mText = text; 77 // spans with timestamp will be enabled by Cue.onTime 78 mEnabled = (mTimestampMs < 0); 79 } 80 81 @Override 82 public boolean equals(Object o) { 83 if (!(o instanceof TextTrackCueSpan)) { 84 return false; 85 } 86 TextTrackCueSpan span = (TextTrackCueSpan) o; 87 return mTimestampMs == span.mTimestampMs && 88 mText.equals(span.mText); 89 } 90} 91 92/** 93 * @hide 94 * 95 * Extract all text without style, but with timestamp spans. 96 */ 97class UnstyledTextExtractor implements Tokenizer.OnTokenListener { 98 StringBuilder mLine = new StringBuilder(); 99 Vector<TextTrackCueSpan[]> mLines = new Vector<TextTrackCueSpan[]>(); 100 Vector<TextTrackCueSpan> mCurrentLine = new Vector<TextTrackCueSpan>(); 101 long mLastTimestamp; 102 103 UnstyledTextExtractor() { 104 init(); 105 } 106 107 private void init() { 108 mLine.delete(0, mLine.length()); 109 mLines.clear(); 110 mCurrentLine.clear(); 111 mLastTimestamp = -1; 112 } 113 114 @Override 115 public void onData(String s) { 116 mLine.append(s); 117 } 118 119 @Override 120 public void onStart(String tag, String[] classes, String annotation) { } 121 122 @Override 123 public void onEnd(String tag) { } 124 125 @Override 126 public void onTimeStamp(long timestampMs) { 127 // finish any prior span 128 if (mLine.length() > 0 && timestampMs != mLastTimestamp) { 129 mCurrentLine.add( 130 new TextTrackCueSpan(mLine.toString(), mLastTimestamp)); 131 mLine.delete(0, mLine.length()); 132 } 133 mLastTimestamp = timestampMs; 134 } 135 136 @Override 137 public void onLineEnd() { 138 // finish any pending span 139 if (mLine.length() > 0) { 140 mCurrentLine.add( 141 new TextTrackCueSpan(mLine.toString(), mLastTimestamp)); 142 mLine.delete(0, mLine.length()); 143 } 144 145 TextTrackCueSpan[] spans = new TextTrackCueSpan[mCurrentLine.size()]; 146 mCurrentLine.toArray(spans); 147 mCurrentLine.clear(); 148 mLines.add(spans); 149 } 150 151 public TextTrackCueSpan[][] getText() { 152 // for politeness, finish last cue-line if it ends abruptly 153 if (mLine.length() > 0 || mCurrentLine.size() > 0) { 154 onLineEnd(); 155 } 156 TextTrackCueSpan[][] lines = new TextTrackCueSpan[mLines.size()][]; 157 mLines.toArray(lines); 158 init(); 159 return lines; 160 } 161} 162 163/** 164 * @hide 165 * 166 * Tokenizer tokenizes the WebVTT Cue Text into tags and data 167 */ 168class Tokenizer { 169 private static final String TAG = "Tokenizer"; 170 private TokenizerPhase mPhase; 171 private TokenizerPhase mDataTokenizer; 172 private TokenizerPhase mTagTokenizer; 173 174 private OnTokenListener mListener; 175 private String mLine; 176 private int mHandledLen; 177 178 interface TokenizerPhase { 179 TokenizerPhase start(); 180 void tokenize(); 181 } 182 183 class DataTokenizer implements TokenizerPhase { 184 // includes both WebVTT data && escape state 185 private StringBuilder mData; 186 187 public TokenizerPhase start() { 188 mData = new StringBuilder(); 189 return this; 190 } 191 192 private boolean replaceEscape(String escape, String replacement, int pos) { 193 if (mLine.startsWith(escape, pos)) { 194 mData.append(mLine.substring(mHandledLen, pos)); 195 mData.append(replacement); 196 mHandledLen = pos + escape.length(); 197 pos = mHandledLen - 1; 198 return true; 199 } 200 return false; 201 } 202 203 @Override 204 public void tokenize() { 205 int end = mLine.length(); 206 for (int pos = mHandledLen; pos < mLine.length(); pos++) { 207 if (mLine.charAt(pos) == '&') { 208 if (replaceEscape("&", "&", pos) || 209 replaceEscape("<", "<", pos) || 210 replaceEscape(">", ">", pos) || 211 replaceEscape("‎", "\u200e", pos) || 212 replaceEscape("‏", "\u200f", pos) || 213 replaceEscape(" ", "\u00a0", pos)) { 214 continue; 215 } 216 } else if (mLine.charAt(pos) == '<') { 217 end = pos; 218 mPhase = mTagTokenizer.start(); 219 break; 220 } 221 } 222 mData.append(mLine.substring(mHandledLen, end)); 223 // yield mData 224 mListener.onData(mData.toString()); 225 mData.delete(0, mData.length()); 226 mHandledLen = end; 227 } 228 } 229 230 class TagTokenizer implements TokenizerPhase { 231 private boolean mAtAnnotation; 232 private String mName, mAnnotation; 233 234 public TokenizerPhase start() { 235 mName = mAnnotation = ""; 236 mAtAnnotation = false; 237 return this; 238 } 239 240 @Override 241 public void tokenize() { 242 if (!mAtAnnotation) 243 mHandledLen++; 244 if (mHandledLen < mLine.length()) { 245 String[] parts; 246 /** 247 * Collect annotations and end-tags to closing >. Collect tag 248 * name to closing bracket or next white-space. 249 */ 250 if (mAtAnnotation || mLine.charAt(mHandledLen) == '/') { 251 parts = mLine.substring(mHandledLen).split(">"); 252 } else { 253 parts = mLine.substring(mHandledLen).split("[\t\f >]"); 254 } 255 String part = mLine.substring( 256 mHandledLen, mHandledLen + parts[0].length()); 257 mHandledLen += parts[0].length(); 258 259 if (mAtAnnotation) { 260 mAnnotation += " " + part; 261 } else { 262 mName = part; 263 } 264 } 265 266 mAtAnnotation = true; 267 268 if (mHandledLen < mLine.length() && mLine.charAt(mHandledLen) == '>') { 269 yield_tag(); 270 mPhase = mDataTokenizer.start(); 271 mHandledLen++; 272 } 273 } 274 275 private void yield_tag() { 276 if (mName.startsWith("/")) { 277 mListener.onEnd(mName.substring(1)); 278 } else if (mName.length() > 0 && Character.isDigit(mName.charAt(0))) { 279 // timestamp 280 try { 281 long timestampMs = WebVttParser.parseTimestampMs(mName); 282 mListener.onTimeStamp(timestampMs); 283 } catch (NumberFormatException e) { 284 Log.d(TAG, "invalid timestamp tag: <" + mName + ">"); 285 } 286 } else { 287 mAnnotation = mAnnotation.replaceAll("\\s+", " "); 288 if (mAnnotation.startsWith(" ")) { 289 mAnnotation = mAnnotation.substring(1); 290 } 291 if (mAnnotation.endsWith(" ")) { 292 mAnnotation = mAnnotation.substring(0, mAnnotation.length() - 1); 293 } 294 295 String[] classes = null; 296 int dotAt = mName.indexOf('.'); 297 if (dotAt >= 0) { 298 classes = mName.substring(dotAt + 1).split("\\."); 299 mName = mName.substring(0, dotAt); 300 } 301 mListener.onStart(mName, classes, mAnnotation); 302 } 303 } 304 } 305 306 Tokenizer(OnTokenListener listener) { 307 mDataTokenizer = new DataTokenizer(); 308 mTagTokenizer = new TagTokenizer(); 309 reset(); 310 mListener = listener; 311 } 312 313 void reset() { 314 mPhase = mDataTokenizer.start(); 315 } 316 317 void tokenize(String s) { 318 mHandledLen = 0; 319 mLine = s; 320 while (mHandledLen < mLine.length()) { 321 mPhase.tokenize(); 322 } 323 /* we are finished with a line unless we are in the middle of a tag */ 324 if (!(mPhase instanceof TagTokenizer)) { 325 // yield END-OF-LINE 326 mListener.onLineEnd(); 327 } 328 } 329 330 interface OnTokenListener { 331 void onData(String s); 332 void onStart(String tag, String[] classes, String annotation); 333 void onEnd(String tag); 334 void onTimeStamp(long timestampMs); 335 void onLineEnd(); 336 } 337} 338 339/** @hide */ 340class TextTrackRegion { 341 final static int SCROLL_VALUE_NONE = 300; 342 final static int SCROLL_VALUE_SCROLL_UP = 301; 343 344 String mId; 345 float mWidth; 346 int mLines; 347 float mAnchorPointX, mAnchorPointY; 348 float mViewportAnchorPointX, mViewportAnchorPointY; 349 int mScrollValue; 350 351 TextTrackRegion() { 352 mId = ""; 353 mWidth = 100; 354 mLines = 3; 355 mAnchorPointX = mViewportAnchorPointX = 0.f; 356 mAnchorPointY = mViewportAnchorPointY = 100.f; 357 mScrollValue = SCROLL_VALUE_NONE; 358 } 359 360 public String toString() { 361 StringBuilder res = new StringBuilder(" {id:\"").append(mId) 362 .append("\", width:").append(mWidth) 363 .append(", lines:").append(mLines) 364 .append(", anchorPoint:(").append(mAnchorPointX) 365 .append(", ").append(mAnchorPointY) 366 .append("), viewportAnchorPoints:").append(mViewportAnchorPointX) 367 .append(", ").append(mViewportAnchorPointY) 368 .append("), scrollValue:") 369 .append(mScrollValue == SCROLL_VALUE_NONE ? "none" : 370 mScrollValue == SCROLL_VALUE_SCROLL_UP ? "scroll_up" : 371 "INVALID") 372 .append("}"); 373 return res.toString(); 374 } 375} 376 377/** @hide */ 378class TextTrackCue extends SubtitleTrack.Cue { 379 final static int WRITING_DIRECTION_HORIZONTAL = 100; 380 final static int WRITING_DIRECTION_VERTICAL_RL = 101; 381 final static int WRITING_DIRECTION_VERTICAL_LR = 102; 382 383 final static int ALIGNMENT_MIDDLE = 200; 384 final static int ALIGNMENT_START = 201; 385 final static int ALIGNMENT_END = 202; 386 final static int ALIGNMENT_LEFT = 203; 387 final static int ALIGNMENT_RIGHT = 204; 388 private static final String TAG = "TTCue"; 389 390 String mId; 391 boolean mPauseOnExit; 392 int mWritingDirection; 393 String mRegionId; 394 boolean mSnapToLines; 395 Integer mLinePosition; // null means AUTO 396 boolean mAutoLinePosition; 397 int mTextPosition; 398 int mSize; 399 int mAlignment; 400 // Vector<String> mText; 401 String[] mStrings; 402 TextTrackCueSpan[][] mLines; 403 TextTrackRegion mRegion; 404 405 TextTrackCue() { 406 mId = ""; 407 mPauseOnExit = false; 408 mWritingDirection = WRITING_DIRECTION_HORIZONTAL; 409 mRegionId = ""; 410 mSnapToLines = true; 411 mLinePosition = null /* AUTO */; 412 mTextPosition = 50; 413 mSize = 100; 414 mAlignment = ALIGNMENT_MIDDLE; 415 mLines = null; 416 mRegion = null; 417 } 418 419 @Override 420 public boolean equals(Object o) { 421 if (!(o instanceof TextTrackCue)) { 422 return false; 423 } 424 if (this == o) { 425 return true; 426 } 427 428 try { 429 TextTrackCue cue = (TextTrackCue) o; 430 boolean res = mId.equals(cue.mId) && 431 mPauseOnExit == cue.mPauseOnExit && 432 mWritingDirection == cue.mWritingDirection && 433 mRegionId.equals(cue.mRegionId) && 434 mSnapToLines == cue.mSnapToLines && 435 mAutoLinePosition == cue.mAutoLinePosition && 436 (mAutoLinePosition || mLinePosition == cue.mLinePosition) && 437 mTextPosition == cue.mTextPosition && 438 mSize == cue.mSize && 439 mAlignment == cue.mAlignment && 440 mLines.length == cue.mLines.length; 441 if (res == true) { 442 for (int line = 0; line < mLines.length; line++) { 443 if (!Arrays.equals(mLines[line], cue.mLines[line])) { 444 return false; 445 } 446 } 447 } 448 return res; 449 } catch(IncompatibleClassChangeError e) { 450 return false; 451 } 452 } 453 454 public StringBuilder appendStringsToBuilder(StringBuilder builder) { 455 if (mStrings == null) { 456 builder.append("null"); 457 } else { 458 builder.append("["); 459 boolean first = true; 460 for (String s: mStrings) { 461 if (!first) { 462 builder.append(", "); 463 } 464 if (s == null) { 465 builder.append("null"); 466 } else { 467 builder.append("\""); 468 builder.append(s); 469 builder.append("\""); 470 } 471 first = false; 472 } 473 builder.append("]"); 474 } 475 return builder; 476 } 477 478 public StringBuilder appendLinesToBuilder(StringBuilder builder) { 479 if (mLines == null) { 480 builder.append("null"); 481 } else { 482 builder.append("["); 483 boolean first = true; 484 for (TextTrackCueSpan[] spans: mLines) { 485 if (!first) { 486 builder.append(", "); 487 } 488 if (spans == null) { 489 builder.append("null"); 490 } else { 491 builder.append("\""); 492 boolean innerFirst = true; 493 long lastTimestamp = -1; 494 for (TextTrackCueSpan span: spans) { 495 if (!innerFirst) { 496 builder.append(" "); 497 } 498 if (span.mTimestampMs != lastTimestamp) { 499 builder.append("<") 500 .append(WebVttParser.timeToString( 501 span.mTimestampMs)) 502 .append(">"); 503 lastTimestamp = span.mTimestampMs; 504 } 505 builder.append(span.mText); 506 innerFirst = false; 507 } 508 builder.append("\""); 509 } 510 first = false; 511 } 512 builder.append("]"); 513 } 514 return builder; 515 } 516 517 public String toString() { 518 StringBuilder res = new StringBuilder(); 519 520 res.append(WebVttParser.timeToString(mStartTimeMs)) 521 .append(" --> ").append(WebVttParser.timeToString(mEndTimeMs)) 522 .append(" {id:\"").append(mId) 523 .append("\", pauseOnExit:").append(mPauseOnExit) 524 .append(", direction:") 525 .append(mWritingDirection == WRITING_DIRECTION_HORIZONTAL ? "horizontal" : 526 mWritingDirection == WRITING_DIRECTION_VERTICAL_LR ? "vertical_lr" : 527 mWritingDirection == WRITING_DIRECTION_VERTICAL_RL ? "vertical_rl" : 528 "INVALID") 529 .append(", regionId:\"").append(mRegionId) 530 .append("\", snapToLines:").append(mSnapToLines) 531 .append(", linePosition:").append(mAutoLinePosition ? "auto" : 532 mLinePosition) 533 .append(", textPosition:").append(mTextPosition) 534 .append(", size:").append(mSize) 535 .append(", alignment:") 536 .append(mAlignment == ALIGNMENT_END ? "end" : 537 mAlignment == ALIGNMENT_LEFT ? "left" : 538 mAlignment == ALIGNMENT_MIDDLE ? "middle" : 539 mAlignment == ALIGNMENT_RIGHT ? "right" : 540 mAlignment == ALIGNMENT_START ? "start" : "INVALID") 541 .append(", text:"); 542 appendStringsToBuilder(res).append("}"); 543 return res.toString(); 544 } 545 546 @Override 547 public int hashCode() { 548 return toString().hashCode(); 549 } 550 551 @Override 552 public void onTime(long timeMs) { 553 for (TextTrackCueSpan[] line: mLines) { 554 for (TextTrackCueSpan span: line) { 555 span.mEnabled = timeMs >= span.mTimestampMs; 556 } 557 } 558 } 559} 560 561/** 562 * Supporting July 10 2013 draft version 563 * 564 * @hide 565 */ 566class WebVttParser { 567 private static final String TAG = "WebVttParser"; 568 private Phase mPhase; 569 private TextTrackCue mCue; 570 private Vector<String> mCueTexts; 571 private WebVttCueListener mListener; 572 private String mBuffer; 573 574 WebVttParser(WebVttCueListener listener) { 575 mPhase = mParseStart; 576 mBuffer = ""; /* mBuffer contains up to 1 incomplete line */ 577 mListener = listener; 578 mCueTexts = new Vector<String>(); 579 } 580 581 /* parsePercentageString */ 582 public static float parseFloatPercentage(String s) 583 throws NumberFormatException { 584 if (!s.endsWith("%")) { 585 throw new NumberFormatException("does not end in %"); 586 } 587 s = s.substring(0, s.length() - 1); 588 // parseFloat allows an exponent or a sign 589 if (s.matches(".*[^0-9.].*")) { 590 throw new NumberFormatException("contains an invalid character"); 591 } 592 593 try { 594 float value = Float.parseFloat(s); 595 if (value < 0.0f || value > 100.0f) { 596 throw new NumberFormatException("is out of range"); 597 } 598 return value; 599 } catch (NumberFormatException e) { 600 throw new NumberFormatException("is not a number"); 601 } 602 } 603 604 public static int parseIntPercentage(String s) throws NumberFormatException { 605 if (!s.endsWith("%")) { 606 throw new NumberFormatException("does not end in %"); 607 } 608 s = s.substring(0, s.length() - 1); 609 // parseInt allows "-0" that returns 0, so check for non-digits 610 if (s.matches(".*[^0-9].*")) { 611 throw new NumberFormatException("contains an invalid character"); 612 } 613 614 try { 615 int value = Integer.parseInt(s); 616 if (value < 0 || value > 100) { 617 throw new NumberFormatException("is out of range"); 618 } 619 return value; 620 } catch (NumberFormatException e) { 621 throw new NumberFormatException("is not a number"); 622 } 623 } 624 625 public static long parseTimestampMs(String s) throws NumberFormatException { 626 if (!s.matches("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}")) { 627 throw new NumberFormatException("has invalid format"); 628 } 629 630 String[] parts = s.split("\\.", 2); 631 long value = 0; 632 for (String group: parts[0].split(":")) { 633 value = value * 60 + Long.parseLong(group); 634 } 635 return value * 1000 + Long.parseLong(parts[1]); 636 } 637 638 public static String timeToString(long timeMs) { 639 return String.format("%d:%02d:%02d.%03d", 640 timeMs / 3600000, (timeMs / 60000) % 60, 641 (timeMs / 1000) % 60, timeMs % 1000); 642 } 643 644 public void parse(String s) { 645 boolean trailingCR = false; 646 mBuffer = (mBuffer + s.replace("\0", "\ufffd")).replace("\r\n", "\n"); 647 648 /* keep trailing '\r' in case matching '\n' arrives in next packet */ 649 if (mBuffer.endsWith("\r")) { 650 trailingCR = true; 651 mBuffer = mBuffer.substring(0, mBuffer.length() - 1); 652 } 653 654 String[] lines = mBuffer.split("[\r\n]"); 655 for (int i = 0; i < lines.length - 1; i++) { 656 mPhase.parse(lines[i]); 657 } 658 659 mBuffer = lines[lines.length - 1]; 660 if (trailingCR) 661 mBuffer += "\r"; 662 } 663 664 public void eos() { 665 if (mBuffer.endsWith("\r")) { 666 mBuffer = mBuffer.substring(0, mBuffer.length() - 1); 667 } 668 669 mPhase.parse(mBuffer); 670 mBuffer = ""; 671 672 yieldCue(); 673 mPhase = mParseStart; 674 } 675 676 public void yieldCue() { 677 if (mCue != null && mCueTexts.size() > 0) { 678 mCue.mStrings = new String[mCueTexts.size()]; 679 mCueTexts.toArray(mCue.mStrings); 680 mCueTexts.clear(); 681 mListener.onCueParsed(mCue); 682 } 683 mCue = null; 684 } 685 686 interface Phase { 687 void parse(String line); 688 } 689 690 final private Phase mSkipRest = new Phase() { 691 @Override 692 public void parse(String line) { } 693 }; 694 695 final private Phase mParseStart = new Phase() { // 5-9 696 @Override 697 public void parse(String line) { 698 if (line.startsWith("\ufeff")) { 699 line = line.substring(1); 700 } 701 if (!line.equals("WEBVTT") && 702 !line.startsWith("WEBVTT ") && 703 !line.startsWith("WEBVTT\t")) { 704 log_warning("Not a WEBVTT header", line); 705 mPhase = mSkipRest; 706 } else { 707 mPhase = mParseHeader; 708 } 709 } 710 }; 711 712 final private Phase mParseHeader = new Phase() { // 10-13 713 TextTrackRegion parseRegion(String s) { 714 TextTrackRegion region = new TextTrackRegion(); 715 for (String setting: s.split(" +")) { 716 int equalAt = setting.indexOf('='); 717 if (equalAt <= 0 || equalAt == setting.length() - 1) { 718 continue; 719 } 720 721 String name = setting.substring(0, equalAt); 722 String value = setting.substring(equalAt + 1); 723 if (name.equals("id")) { 724 region.mId = value; 725 } else if (name.equals("width")) { 726 try { 727 region.mWidth = parseFloatPercentage(value); 728 } catch (NumberFormatException e) { 729 log_warning("region setting", name, 730 "has invalid value", e.getMessage(), value); 731 } 732 } else if (name.equals("lines")) { 733 if (value.matches(".*[^0-9].*")) { 734 log_warning("lines", name, "contains an invalid character", value); 735 } else { 736 try { 737 region.mLines = Integer.parseInt(value); 738 assert(region.mLines >= 0); // lines contains only digits 739 } catch (NumberFormatException e) { 740 log_warning("region setting", name, "is not numeric", value); 741 } 742 } 743 } else if (name.equals("regionanchor") || 744 name.equals("viewportanchor")) { 745 int commaAt = value.indexOf(","); 746 if (commaAt < 0) { 747 log_warning("region setting", name, "contains no comma", value); 748 continue; 749 } 750 751 String anchorX = value.substring(0, commaAt); 752 String anchorY = value.substring(commaAt + 1); 753 float x, y; 754 755 try { 756 x = parseFloatPercentage(anchorX); 757 } catch (NumberFormatException e) { 758 log_warning("region setting", name, 759 "has invalid x component", e.getMessage(), anchorX); 760 continue; 761 } 762 try { 763 y = parseFloatPercentage(anchorY); 764 } catch (NumberFormatException e) { 765 log_warning("region setting", name, 766 "has invalid y component", e.getMessage(), anchorY); 767 continue; 768 } 769 770 if (name.charAt(0) == 'r') { 771 region.mAnchorPointX = x; 772 region.mAnchorPointY = y; 773 } else { 774 region.mViewportAnchorPointX = x; 775 region.mViewportAnchorPointY = y; 776 } 777 } else if (name.equals("scroll")) { 778 if (value.equals("up")) { 779 region.mScrollValue = 780 TextTrackRegion.SCROLL_VALUE_SCROLL_UP; 781 } else { 782 log_warning("region setting", name, "has invalid value", value); 783 } 784 } 785 } 786 return region; 787 } 788 789 @Override 790 public void parse(String line) { 791 if (line.length() == 0) { 792 mPhase = mParseCueId; 793 } else if (line.contains("-->")) { 794 mPhase = mParseCueTime; 795 mPhase.parse(line); 796 } else { 797 int colonAt = line.indexOf(':'); 798 if (colonAt <= 0 || colonAt >= line.length() - 1) { 799 log_warning("meta data header has invalid format", line); 800 } 801 String name = line.substring(0, colonAt); 802 String value = line.substring(colonAt + 1); 803 804 if (name.equals("Region")) { 805 TextTrackRegion region = parseRegion(value); 806 mListener.onRegionParsed(region); 807 } 808 } 809 } 810 }; 811 812 final private Phase mParseCueId = new Phase() { 813 @Override 814 public void parse(String line) { 815 if (line.length() == 0) { 816 return; 817 } 818 819 assert(mCue == null); 820 821 if (line.equals("NOTE") || line.startsWith("NOTE ")) { 822 mPhase = mParseCueText; 823 } 824 825 mCue = new TextTrackCue(); 826 mCueTexts.clear(); 827 828 mPhase = mParseCueTime; 829 if (line.contains("-->")) { 830 mPhase.parse(line); 831 } else { 832 mCue.mId = line; 833 } 834 } 835 }; 836 837 final private Phase mParseCueTime = new Phase() { 838 @Override 839 public void parse(String line) { 840 int arrowAt = line.indexOf("-->"); 841 if (arrowAt < 0) { 842 mCue = null; 843 mPhase = mParseCueId; 844 return; 845 } 846 847 String start = line.substring(0, arrowAt).trim(); 848 // convert only initial and first other white-space to space 849 String rest = line.substring(arrowAt + 3) 850 .replaceFirst("^\\s+", "").replaceFirst("\\s+", " "); 851 int spaceAt = rest.indexOf(' '); 852 String end = spaceAt > 0 ? rest.substring(0, spaceAt) : rest; 853 rest = spaceAt > 0 ? rest.substring(spaceAt + 1) : ""; 854 855 mCue.mStartTimeMs = parseTimestampMs(start); 856 mCue.mEndTimeMs = parseTimestampMs(end); 857 for (String setting: rest.split(" +")) { 858 int colonAt = setting.indexOf(':'); 859 if (colonAt <= 0 || colonAt == setting.length() - 1) { 860 continue; 861 } 862 String name = setting.substring(0, colonAt); 863 String value = setting.substring(colonAt + 1); 864 865 if (name.equals("region")) { 866 mCue.mRegionId = value; 867 } else if (name.equals("vertical")) { 868 if (value.equals("rl")) { 869 mCue.mWritingDirection = 870 TextTrackCue.WRITING_DIRECTION_VERTICAL_RL; 871 } else if (value.equals("lr")) { 872 mCue.mWritingDirection = 873 TextTrackCue.WRITING_DIRECTION_VERTICAL_LR; 874 } else { 875 log_warning("cue setting", name, "has invalid value", value); 876 } 877 } else if (name.equals("line")) { 878 try { 879 /* TRICKY: we know that there are no spaces in value */ 880 assert(value.indexOf(' ') < 0); 881 if (value.endsWith("%")) { 882 mCue.mSnapToLines = false; 883 mCue.mLinePosition = parseIntPercentage(value); 884 } else if (value.matches(".*[^0-9].*")) { 885 log_warning("cue setting", name, 886 "contains an invalid character", value); 887 } else { 888 mCue.mSnapToLines = true; 889 mCue.mLinePosition = Integer.parseInt(value); 890 } 891 } catch (NumberFormatException e) { 892 log_warning("cue setting", name, 893 "is not numeric or percentage", value); 894 } 895 // TODO: add support for optional alignment value [,start|middle|end] 896 } else if (name.equals("position")) { 897 try { 898 mCue.mTextPosition = parseIntPercentage(value); 899 } catch (NumberFormatException e) { 900 log_warning("cue setting", name, 901 "is not numeric or percentage", value); 902 } 903 } else if (name.equals("size")) { 904 try { 905 mCue.mSize = parseIntPercentage(value); 906 } catch (NumberFormatException e) { 907 log_warning("cue setting", name, 908 "is not numeric or percentage", value); 909 } 910 } else if (name.equals("align")) { 911 if (value.equals("start")) { 912 mCue.mAlignment = TextTrackCue.ALIGNMENT_START; 913 } else if (value.equals("middle")) { 914 mCue.mAlignment = TextTrackCue.ALIGNMENT_MIDDLE; 915 } else if (value.equals("end")) { 916 mCue.mAlignment = TextTrackCue.ALIGNMENT_END; 917 } else if (value.equals("left")) { 918 mCue.mAlignment = TextTrackCue.ALIGNMENT_LEFT; 919 } else if (value.equals("right")) { 920 mCue.mAlignment = TextTrackCue.ALIGNMENT_RIGHT; 921 } else { 922 log_warning("cue setting", name, "has invalid value", value); 923 continue; 924 } 925 } 926 } 927 928 if (mCue.mLinePosition != null || 929 mCue.mSize != 100 || 930 (mCue.mWritingDirection != 931 TextTrackCue.WRITING_DIRECTION_HORIZONTAL)) { 932 mCue.mRegionId = ""; 933 } 934 935 mPhase = mParseCueText; 936 } 937 }; 938 939 /* also used for notes */ 940 final private Phase mParseCueText = new Phase() { 941 @Override 942 public void parse(String line) { 943 if (line.length() == 0) { 944 yieldCue(); 945 mPhase = mParseCueId; 946 return; 947 } else if (mCue != null) { 948 mCueTexts.add(line); 949 } 950 } 951 }; 952 953 private void log_warning( 954 String nameType, String name, String message, 955 String subMessage, String value) { 956 Log.w(this.getClass().getName(), nameType + " '" + name + "' " + 957 message + " ('" + value + "' " + subMessage + ")"); 958 } 959 960 private void log_warning( 961 String nameType, String name, String message, String value) { 962 Log.w(this.getClass().getName(), nameType + " '" + name + "' " + 963 message + " ('" + value + "')"); 964 } 965 966 private void log_warning(String message, String value) { 967 Log.w(this.getClass().getName(), message + " ('" + value + "')"); 968 } 969} 970 971/** @hide */ 972interface WebVttCueListener { 973 void onCueParsed(TextTrackCue cue); 974 void onRegionParsed(TextTrackRegion region); 975} 976 977/** @hide */ 978class WebVttTrack extends SubtitleTrack implements WebVttCueListener { 979 private static final String TAG = "WebVttTrack"; 980 981 private final WebVttParser mParser = new WebVttParser(this); 982 private final UnstyledTextExtractor mExtractor = 983 new UnstyledTextExtractor(); 984 private final Tokenizer mTokenizer = new Tokenizer(mExtractor); 985 private final Vector<Long> mTimestamps = new Vector<Long>(); 986 private final WebVttRenderingWidget mRenderingWidget; 987 988 private final Map<String, TextTrackRegion> mRegions = 989 new HashMap<String, TextTrackRegion>(); 990 private Long mCurrentRunID; 991 992 WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format) { 993 super(format); 994 995 mRenderingWidget = renderingWidget; 996 } 997 998 @Override 999 public WebVttRenderingWidget getRenderingWidget() { 1000 return mRenderingWidget; 1001 } 1002 1003 @Override 1004 public void onData(byte[] data, boolean eos, long runID) { 1005 try { 1006 String str = new String(data, "UTF-8"); 1007 1008 // implement intermixing restriction for WebVTT only for now 1009 synchronized(mParser) { 1010 if (mCurrentRunID != null && runID != mCurrentRunID) { 1011 throw new IllegalStateException( 1012 "Run #" + mCurrentRunID + 1013 " in progress. Cannot process run #" + runID); 1014 } 1015 mCurrentRunID = runID; 1016 mParser.parse(str); 1017 if (eos) { 1018 finishedRun(runID); 1019 mParser.eos(); 1020 mRegions.clear(); 1021 mCurrentRunID = null; 1022 } 1023 } 1024 } catch (java.io.UnsupportedEncodingException e) { 1025 Log.w(TAG, "subtitle data is not UTF-8 encoded: " + e); 1026 } 1027 } 1028 1029 @Override 1030 public void onCueParsed(TextTrackCue cue) { 1031 synchronized (mParser) { 1032 // resolve region 1033 if (cue.mRegionId.length() != 0) { 1034 cue.mRegion = mRegions.get(cue.mRegionId); 1035 } 1036 1037 if (DEBUG) Log.v(TAG, "adding cue " + cue); 1038 1039 // tokenize text track string-lines into lines of spans 1040 mTokenizer.reset(); 1041 for (String s: cue.mStrings) { 1042 mTokenizer.tokenize(s); 1043 } 1044 cue.mLines = mExtractor.getText(); 1045 if (DEBUG) Log.v(TAG, cue.appendLinesToBuilder( 1046 cue.appendStringsToBuilder( 1047 new StringBuilder()).append(" simplified to: ")) 1048 .toString()); 1049 1050 // extract inner timestamps 1051 for (TextTrackCueSpan[] line: cue.mLines) { 1052 for (TextTrackCueSpan span: line) { 1053 if (span.mTimestampMs > cue.mStartTimeMs && 1054 span.mTimestampMs < cue.mEndTimeMs && 1055 !mTimestamps.contains(span.mTimestampMs)) { 1056 mTimestamps.add(span.mTimestampMs); 1057 } 1058 } 1059 } 1060 1061 if (mTimestamps.size() > 0) { 1062 cue.mInnerTimesMs = new long[mTimestamps.size()]; 1063 for (int ix=0; ix < mTimestamps.size(); ++ix) { 1064 cue.mInnerTimesMs[ix] = mTimestamps.get(ix); 1065 } 1066 mTimestamps.clear(); 1067 } else { 1068 cue.mInnerTimesMs = null; 1069 } 1070 1071 cue.mRunID = mCurrentRunID; 1072 } 1073 1074 addCue(cue); 1075 } 1076 1077 @Override 1078 public void onRegionParsed(TextTrackRegion region) { 1079 synchronized(mParser) { 1080 mRegions.put(region.mId, region); 1081 } 1082 } 1083 1084 @Override 1085 public void updateView(Vector<SubtitleTrack.Cue> activeCues) { 1086 if (!mVisible) { 1087 // don't keep the state if we are not visible 1088 return; 1089 } 1090 1091 if (DEBUG && mTimeProvider != null) { 1092 try { 1093 Log.d(TAG, "at " + 1094 (mTimeProvider.getCurrentTimeUs(false, true) / 1000) + 1095 " ms the active cues are:"); 1096 } catch (IllegalStateException e) { 1097 Log.d(TAG, "at (illegal state) the active cues are:"); 1098 } 1099 } 1100 1101 if (mRenderingWidget != null) { 1102 mRenderingWidget.setActiveCues(activeCues); 1103 } 1104 } 1105} 1106 1107/** 1108 * Widget capable of rendering WebVTT captions. 1109 * 1110 * @hide 1111 */ 1112class WebVttRenderingWidget extends ViewGroup implements SubtitleTrack.RenderingWidget { 1113 private static final boolean DEBUG = false; 1114 1115 private static final CaptionStyle DEFAULT_CAPTION_STYLE = CaptionStyle.DEFAULT; 1116 1117 private static final int DEBUG_REGION_BACKGROUND = 0x800000FF; 1118 private static final int DEBUG_CUE_BACKGROUND = 0x80FF0000; 1119 1120 /** WebVtt specifies line height as 5.3% of the viewport height. */ 1121 private static final float LINE_HEIGHT_RATIO = 0.0533f; 1122 1123 /** Map of active regions, used to determine enter/exit. */ 1124 private final ArrayMap<TextTrackRegion, RegionLayout> mRegionBoxes = 1125 new ArrayMap<TextTrackRegion, RegionLayout>(); 1126 1127 /** Map of active cues, used to determine enter/exit. */ 1128 private final ArrayMap<TextTrackCue, CueLayout> mCueBoxes = 1129 new ArrayMap<TextTrackCue, CueLayout>(); 1130 1131 /** Captioning manager, used to obtain and track caption properties. */ 1132 private final CaptioningManager mManager; 1133 1134 /** Callback for rendering changes. */ 1135 private OnChangedListener mListener; 1136 1137 /** Current caption style. */ 1138 private CaptionStyle mCaptionStyle; 1139 1140 /** Current font size, computed from font scaling factor and height. */ 1141 private float mFontSize; 1142 1143 /** Whether a caption style change listener is registered. */ 1144 private boolean mHasChangeListener; 1145 1146 public WebVttRenderingWidget(Context context) { 1147 this(context, null); 1148 } 1149 1150 public WebVttRenderingWidget(Context context, AttributeSet attrs) { 1151 this(context, attrs, 0); 1152 } 1153 1154 public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr) { 1155 this(context, attrs, defStyleAttr, 0); 1156 } 1157 1158 public WebVttRenderingWidget( 1159 Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 1160 super(context, attrs, defStyleAttr, defStyleRes); 1161 1162 // Cannot render text over video when layer type is hardware. 1163 setLayerType(View.LAYER_TYPE_SOFTWARE, null); 1164 1165 mManager = (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); 1166 mCaptionStyle = mManager.getUserStyle(); 1167 mFontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO; 1168 } 1169 1170 @Override 1171 public void setSize(int width, int height) { 1172 final int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); 1173 final int heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); 1174 1175 measure(widthSpec, heightSpec); 1176 layout(0, 0, width, height); 1177 } 1178 1179 @Override 1180 public void onAttachedToWindow() { 1181 super.onAttachedToWindow(); 1182 1183 manageChangeListener(); 1184 } 1185 1186 @Override 1187 public void onDetachedFromWindow() { 1188 super.onDetachedFromWindow(); 1189 1190 manageChangeListener(); 1191 } 1192 1193 @Override 1194 public void setOnChangedListener(OnChangedListener listener) { 1195 mListener = listener; 1196 } 1197 1198 @Override 1199 public void setVisible(boolean visible) { 1200 if (visible) { 1201 setVisibility(View.VISIBLE); 1202 } else { 1203 setVisibility(View.GONE); 1204 } 1205 1206 manageChangeListener(); 1207 } 1208 1209 /** 1210 * Manages whether this renderer is listening for caption style changes. 1211 */ 1212 private void manageChangeListener() { 1213 final boolean needsListener = isAttachedToWindow() && getVisibility() == View.VISIBLE; 1214 if (mHasChangeListener != needsListener) { 1215 mHasChangeListener = needsListener; 1216 1217 if (needsListener) { 1218 mManager.addCaptioningChangeListener(mCaptioningListener); 1219 1220 final CaptionStyle captionStyle = mManager.getUserStyle(); 1221 final float fontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO; 1222 setCaptionStyle(captionStyle, fontSize); 1223 } else { 1224 mManager.removeCaptioningChangeListener(mCaptioningListener); 1225 } 1226 } 1227 } 1228 1229 public void setActiveCues(Vector<SubtitleTrack.Cue> activeCues) { 1230 final Context context = getContext(); 1231 final CaptionStyle captionStyle = mCaptionStyle; 1232 final float fontSize = mFontSize; 1233 1234 prepForPrune(); 1235 1236 // Ensure we have all necessary cue and region boxes. 1237 final int count = activeCues.size(); 1238 for (int i = 0; i < count; i++) { 1239 final TextTrackCue cue = (TextTrackCue) activeCues.get(i); 1240 final TextTrackRegion region = cue.mRegion; 1241 if (region != null) { 1242 RegionLayout regionBox = mRegionBoxes.get(region); 1243 if (regionBox == null) { 1244 regionBox = new RegionLayout(context, region, captionStyle, fontSize); 1245 mRegionBoxes.put(region, regionBox); 1246 addView(regionBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1247 } 1248 regionBox.put(cue); 1249 } else { 1250 CueLayout cueBox = mCueBoxes.get(cue); 1251 if (cueBox == null) { 1252 cueBox = new CueLayout(context, cue, captionStyle, fontSize); 1253 mCueBoxes.put(cue, cueBox); 1254 addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1255 } 1256 cueBox.update(); 1257 cueBox.setOrder(i); 1258 } 1259 } 1260 1261 prune(); 1262 1263 // Force measurement and layout. 1264 final int width = getWidth(); 1265 final int height = getHeight(); 1266 setSize(width, height); 1267 1268 if (mListener != null) { 1269 mListener.onChanged(this); 1270 } 1271 } 1272 1273 private void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1274 captionStyle = DEFAULT_CAPTION_STYLE.applyStyle(captionStyle); 1275 mCaptionStyle = captionStyle; 1276 mFontSize = fontSize; 1277 1278 final int cueCount = mCueBoxes.size(); 1279 for (int i = 0; i < cueCount; i++) { 1280 final CueLayout cueBox = mCueBoxes.valueAt(i); 1281 cueBox.setCaptionStyle(captionStyle, fontSize); 1282 } 1283 1284 final int regionCount = mRegionBoxes.size(); 1285 for (int i = 0; i < regionCount; i++) { 1286 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1287 regionBox.setCaptionStyle(captionStyle, fontSize); 1288 } 1289 } 1290 1291 /** 1292 * Remove inactive cues and regions. 1293 */ 1294 private void prune() { 1295 int regionCount = mRegionBoxes.size(); 1296 for (int i = 0; i < regionCount; i++) { 1297 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1298 if (regionBox.prune()) { 1299 removeView(regionBox); 1300 mRegionBoxes.removeAt(i); 1301 regionCount--; 1302 i--; 1303 } 1304 } 1305 1306 int cueCount = mCueBoxes.size(); 1307 for (int i = 0; i < cueCount; i++) { 1308 final CueLayout cueBox = mCueBoxes.valueAt(i); 1309 if (!cueBox.isActive()) { 1310 removeView(cueBox); 1311 mCueBoxes.removeAt(i); 1312 cueCount--; 1313 i--; 1314 } 1315 } 1316 } 1317 1318 /** 1319 * Reset active cues and regions. 1320 */ 1321 private void prepForPrune() { 1322 final int regionCount = mRegionBoxes.size(); 1323 for (int i = 0; i < regionCount; i++) { 1324 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1325 regionBox.prepForPrune(); 1326 } 1327 1328 final int cueCount = mCueBoxes.size(); 1329 for (int i = 0; i < cueCount; i++) { 1330 final CueLayout cueBox = mCueBoxes.valueAt(i); 1331 cueBox.prepForPrune(); 1332 } 1333 } 1334 1335 @Override 1336 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1337 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1338 1339 final int regionCount = mRegionBoxes.size(); 1340 for (int i = 0; i < regionCount; i++) { 1341 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1342 regionBox.measureForParent(widthMeasureSpec, heightMeasureSpec); 1343 } 1344 1345 final int cueCount = mCueBoxes.size(); 1346 for (int i = 0; i < cueCount; i++) { 1347 final CueLayout cueBox = mCueBoxes.valueAt(i); 1348 cueBox.measureForParent(widthMeasureSpec, heightMeasureSpec); 1349 } 1350 } 1351 1352 @Override 1353 protected void onLayout(boolean changed, int l, int t, int r, int b) { 1354 final int viewportWidth = r - l; 1355 final int viewportHeight = b - t; 1356 1357 setCaptionStyle(mCaptionStyle, 1358 mManager.getFontScale() * LINE_HEIGHT_RATIO * viewportHeight); 1359 1360 final int regionCount = mRegionBoxes.size(); 1361 for (int i = 0; i < regionCount; i++) { 1362 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1363 layoutRegion(viewportWidth, viewportHeight, regionBox); 1364 } 1365 1366 final int cueCount = mCueBoxes.size(); 1367 for (int i = 0; i < cueCount; i++) { 1368 final CueLayout cueBox = mCueBoxes.valueAt(i); 1369 layoutCue(viewportWidth, viewportHeight, cueBox); 1370 } 1371 } 1372 1373 /** 1374 * Lays out a region within the viewport. The region handles layout for 1375 * contained cues. 1376 */ 1377 private void layoutRegion( 1378 int viewportWidth, int viewportHeight, 1379 RegionLayout regionBox) { 1380 final TextTrackRegion region = regionBox.getRegion(); 1381 final int regionHeight = regionBox.getMeasuredHeight(); 1382 final int regionWidth = regionBox.getMeasuredWidth(); 1383 1384 // TODO: Account for region anchor point. 1385 final float x = region.mViewportAnchorPointX; 1386 final float y = region.mViewportAnchorPointY; 1387 final int left = (int) (x * (viewportWidth - regionWidth) / 100); 1388 final int top = (int) (y * (viewportHeight - regionHeight) / 100); 1389 1390 regionBox.layout(left, top, left + regionWidth, top + regionHeight); 1391 } 1392 1393 /** 1394 * Lays out a cue within the viewport. 1395 */ 1396 private void layoutCue( 1397 int viewportWidth, int viewportHeight, CueLayout cueBox) { 1398 final TextTrackCue cue = cueBox.getCue(); 1399 final int direction = getLayoutDirection(); 1400 final int absAlignment = resolveCueAlignment(direction, cue.mAlignment); 1401 final boolean cueSnapToLines = cue.mSnapToLines; 1402 1403 int size = 100 * cueBox.getMeasuredWidth() / viewportWidth; 1404 1405 // Determine raw x-position. 1406 int xPosition; 1407 switch (absAlignment) { 1408 case TextTrackCue.ALIGNMENT_LEFT: 1409 xPosition = cue.mTextPosition; 1410 break; 1411 case TextTrackCue.ALIGNMENT_RIGHT: 1412 xPosition = cue.mTextPosition - size; 1413 break; 1414 case TextTrackCue.ALIGNMENT_MIDDLE: 1415 default: 1416 xPosition = cue.mTextPosition - size / 2; 1417 break; 1418 } 1419 1420 // Adjust x-position for layout. 1421 if (direction == LAYOUT_DIRECTION_RTL) { 1422 xPosition = 100 - xPosition; 1423 } 1424 1425 // If the text track cue snap-to-lines flag is set, adjust 1426 // x-position and size for padding. This is equivalent to placing the 1427 // cue within the title-safe area. 1428 if (cueSnapToLines) { 1429 final int paddingLeft = 100 * getPaddingLeft() / viewportWidth; 1430 final int paddingRight = 100 * getPaddingRight() / viewportWidth; 1431 if (xPosition < paddingLeft && xPosition + size > paddingLeft) { 1432 xPosition += paddingLeft; 1433 size -= paddingLeft; 1434 } 1435 final float rightEdge = 100 - paddingRight; 1436 if (xPosition < rightEdge && xPosition + size > rightEdge) { 1437 size -= paddingRight; 1438 } 1439 } 1440 1441 // Compute absolute left position and width. 1442 final int left = xPosition * viewportWidth / 100; 1443 final int width = size * viewportWidth / 100; 1444 1445 // Determine initial y-position. 1446 final int yPosition = calculateLinePosition(cueBox); 1447 1448 // Compute absolute final top position and height. 1449 final int height = cueBox.getMeasuredHeight(); 1450 final int top; 1451 if (yPosition < 0) { 1452 // TODO: This needs to use the actual height of prior boxes. 1453 top = viewportHeight + yPosition * height; 1454 } else { 1455 top = yPosition * (viewportHeight - height) / 100; 1456 } 1457 1458 // Layout cue in final position. 1459 cueBox.layout(left, top, left + width, top + height); 1460 } 1461 1462 /** 1463 * Calculates the line position for a cue. 1464 * <p> 1465 * If the resulting position is negative, it represents a bottom-aligned 1466 * position relative to the number of active cues. Otherwise, it represents 1467 * a percentage [0-100] of the viewport height. 1468 */ 1469 private int calculateLinePosition(CueLayout cueBox) { 1470 final TextTrackCue cue = cueBox.getCue(); 1471 final Integer linePosition = cue.mLinePosition; 1472 final boolean snapToLines = cue.mSnapToLines; 1473 final boolean autoPosition = (linePosition == null); 1474 1475 if (!snapToLines && !autoPosition && (linePosition < 0 || linePosition > 100)) { 1476 // Invalid line position defaults to 100. 1477 return 100; 1478 } else if (!autoPosition) { 1479 // Use the valid, supplied line position. 1480 return linePosition; 1481 } else if (!snapToLines) { 1482 // Automatic, non-snapped line position defaults to 100. 1483 return 100; 1484 } else { 1485 // Automatic snapped line position uses active cue order. 1486 return -(cueBox.mOrder + 1); 1487 } 1488 } 1489 1490 /** 1491 * Resolves cue alignment according to the specified layout direction. 1492 */ 1493 private static int resolveCueAlignment(int layoutDirection, int alignment) { 1494 switch (alignment) { 1495 case TextTrackCue.ALIGNMENT_START: 1496 return layoutDirection == View.LAYOUT_DIRECTION_LTR ? 1497 TextTrackCue.ALIGNMENT_LEFT : TextTrackCue.ALIGNMENT_RIGHT; 1498 case TextTrackCue.ALIGNMENT_END: 1499 return layoutDirection == View.LAYOUT_DIRECTION_LTR ? 1500 TextTrackCue.ALIGNMENT_RIGHT : TextTrackCue.ALIGNMENT_LEFT; 1501 } 1502 return alignment; 1503 } 1504 1505 private final CaptioningChangeListener mCaptioningListener = new CaptioningChangeListener() { 1506 @Override 1507 public void onFontScaleChanged(float fontScale) { 1508 final float fontSize = fontScale * getHeight() * LINE_HEIGHT_RATIO; 1509 setCaptionStyle(mCaptionStyle, fontSize); 1510 } 1511 1512 @Override 1513 public void onUserStyleChanged(CaptionStyle userStyle) { 1514 setCaptionStyle(userStyle, mFontSize); 1515 } 1516 }; 1517 1518 /** 1519 * A text track region represents a portion of the video viewport and 1520 * provides a rendering area for text track cues. 1521 */ 1522 private static class RegionLayout extends LinearLayout { 1523 private final ArrayList<CueLayout> mRegionCueBoxes = new ArrayList<CueLayout>(); 1524 private final TextTrackRegion mRegion; 1525 1526 private CaptionStyle mCaptionStyle; 1527 private float mFontSize; 1528 1529 public RegionLayout(Context context, TextTrackRegion region, CaptionStyle captionStyle, 1530 float fontSize) { 1531 super(context); 1532 1533 mRegion = region; 1534 mCaptionStyle = captionStyle; 1535 mFontSize = fontSize; 1536 1537 // TODO: Add support for vertical text 1538 setOrientation(VERTICAL); 1539 1540 if (DEBUG) { 1541 setBackgroundColor(DEBUG_REGION_BACKGROUND); 1542 } else { 1543 setBackgroundColor(captionStyle.windowColor); 1544 } 1545 } 1546 1547 public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1548 mCaptionStyle = captionStyle; 1549 mFontSize = fontSize; 1550 1551 final int cueCount = mRegionCueBoxes.size(); 1552 for (int i = 0; i < cueCount; i++) { 1553 final CueLayout cueBox = mRegionCueBoxes.get(i); 1554 cueBox.setCaptionStyle(captionStyle, fontSize); 1555 } 1556 1557 setBackgroundColor(captionStyle.windowColor); 1558 } 1559 1560 /** 1561 * Performs the parent's measurement responsibilities, then 1562 * automatically performs its own measurement. 1563 */ 1564 public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) { 1565 final TextTrackRegion region = mRegion; 1566 final int specWidth = MeasureSpec.getSize(widthMeasureSpec); 1567 final int specHeight = MeasureSpec.getSize(heightMeasureSpec); 1568 final int width = (int) region.mWidth; 1569 1570 // Determine the absolute maximum region size as the requested size. 1571 final int size = width * specWidth / 100; 1572 1573 widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST); 1574 heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST); 1575 measure(widthMeasureSpec, heightMeasureSpec); 1576 } 1577 1578 /** 1579 * Prepares this region for pruning by setting all tracks as inactive. 1580 * <p> 1581 * Tracks that are added or updated using {@link #put(TextTrackCue)} 1582 * after this calling this method will be marked as active. 1583 */ 1584 public void prepForPrune() { 1585 final int cueCount = mRegionCueBoxes.size(); 1586 for (int i = 0; i < cueCount; i++) { 1587 final CueLayout cueBox = mRegionCueBoxes.get(i); 1588 cueBox.prepForPrune(); 1589 } 1590 } 1591 1592 /** 1593 * Adds a {@link TextTrackCue} to this region. If the track had already 1594 * been added, updates its active state. 1595 * 1596 * @param cue 1597 */ 1598 public void put(TextTrackCue cue) { 1599 final int cueCount = mRegionCueBoxes.size(); 1600 for (int i = 0; i < cueCount; i++) { 1601 final CueLayout cueBox = mRegionCueBoxes.get(i); 1602 if (cueBox.getCue() == cue) { 1603 cueBox.update(); 1604 return; 1605 } 1606 } 1607 1608 final CueLayout cueBox = new CueLayout(getContext(), cue, mCaptionStyle, mFontSize); 1609 mRegionCueBoxes.add(cueBox); 1610 addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1611 1612 if (getChildCount() > mRegion.mLines) { 1613 removeViewAt(0); 1614 } 1615 } 1616 1617 /** 1618 * Remove all inactive tracks from this region. 1619 * 1620 * @return true if this region is empty and should be pruned 1621 */ 1622 public boolean prune() { 1623 int cueCount = mRegionCueBoxes.size(); 1624 for (int i = 0; i < cueCount; i++) { 1625 final CueLayout cueBox = mRegionCueBoxes.get(i); 1626 if (!cueBox.isActive()) { 1627 mRegionCueBoxes.remove(i); 1628 removeView(cueBox); 1629 cueCount--; 1630 i--; 1631 } 1632 } 1633 1634 return mRegionCueBoxes.isEmpty(); 1635 } 1636 1637 /** 1638 * @return the region data backing this layout 1639 */ 1640 public TextTrackRegion getRegion() { 1641 return mRegion; 1642 } 1643 } 1644 1645 /** 1646 * A text track cue is the unit of time-sensitive data in a text track, 1647 * corresponding for instance for subtitles and captions to the text that 1648 * appears at a particular time and disappears at another time. 1649 * <p> 1650 * A single cue may contain multiple {@link SpanLayout}s, each representing a 1651 * single line of text. 1652 */ 1653 private static class CueLayout extends LinearLayout { 1654 public final TextTrackCue mCue; 1655 1656 private CaptionStyle mCaptionStyle; 1657 private float mFontSize; 1658 1659 private boolean mActive; 1660 private int mOrder; 1661 1662 public CueLayout( 1663 Context context, TextTrackCue cue, CaptionStyle captionStyle, float fontSize) { 1664 super(context); 1665 1666 mCue = cue; 1667 mCaptionStyle = captionStyle; 1668 mFontSize = fontSize; 1669 1670 // TODO: Add support for vertical text. 1671 final boolean horizontal = cue.mWritingDirection 1672 == TextTrackCue.WRITING_DIRECTION_HORIZONTAL; 1673 setOrientation(horizontal ? VERTICAL : HORIZONTAL); 1674 1675 switch (cue.mAlignment) { 1676 case TextTrackCue.ALIGNMENT_END: 1677 setGravity(Gravity.END); 1678 break; 1679 case TextTrackCue.ALIGNMENT_LEFT: 1680 setGravity(Gravity.LEFT); 1681 break; 1682 case TextTrackCue.ALIGNMENT_MIDDLE: 1683 setGravity(horizontal 1684 ? Gravity.CENTER_HORIZONTAL : Gravity.CENTER_VERTICAL); 1685 break; 1686 case TextTrackCue.ALIGNMENT_RIGHT: 1687 setGravity(Gravity.RIGHT); 1688 break; 1689 case TextTrackCue.ALIGNMENT_START: 1690 setGravity(Gravity.START); 1691 break; 1692 } 1693 1694 if (DEBUG) { 1695 setBackgroundColor(DEBUG_CUE_BACKGROUND); 1696 } 1697 1698 update(); 1699 } 1700 1701 public void setCaptionStyle(CaptionStyle style, float fontSize) { 1702 mCaptionStyle = style; 1703 mFontSize = fontSize; 1704 1705 final int n = getChildCount(); 1706 for (int i = 0; i < n; i++) { 1707 final View child = getChildAt(i); 1708 if (child instanceof SpanLayout) { 1709 ((SpanLayout) child).setCaptionStyle(style, fontSize); 1710 } 1711 } 1712 } 1713 1714 public void prepForPrune() { 1715 mActive = false; 1716 } 1717 1718 public void update() { 1719 mActive = true; 1720 1721 removeAllViews(); 1722 1723 final int cueAlignment = resolveCueAlignment(getLayoutDirection(), mCue.mAlignment); 1724 final Alignment alignment; 1725 switch (cueAlignment) { 1726 case TextTrackCue.ALIGNMENT_LEFT: 1727 alignment = Alignment.ALIGN_LEFT; 1728 break; 1729 case TextTrackCue.ALIGNMENT_RIGHT: 1730 alignment = Alignment.ALIGN_RIGHT; 1731 break; 1732 case TextTrackCue.ALIGNMENT_MIDDLE: 1733 default: 1734 alignment = Alignment.ALIGN_CENTER; 1735 } 1736 1737 final CaptionStyle captionStyle = mCaptionStyle; 1738 final float fontSize = mFontSize; 1739 final TextTrackCueSpan[][] lines = mCue.mLines; 1740 final int lineCount = lines.length; 1741 for (int i = 0; i < lineCount; i++) { 1742 final SpanLayout lineBox = new SpanLayout(getContext(), lines[i]); 1743 lineBox.setAlignment(alignment); 1744 lineBox.setCaptionStyle(captionStyle, fontSize); 1745 1746 addView(lineBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1747 } 1748 } 1749 1750 @Override 1751 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1752 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1753 } 1754 1755 /** 1756 * Performs the parent's measurement responsibilities, then 1757 * automatically performs its own measurement. 1758 */ 1759 public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) { 1760 final TextTrackCue cue = mCue; 1761 final int specWidth = MeasureSpec.getSize(widthMeasureSpec); 1762 final int specHeight = MeasureSpec.getSize(heightMeasureSpec); 1763 final int direction = getLayoutDirection(); 1764 final int absAlignment = resolveCueAlignment(direction, cue.mAlignment); 1765 1766 // Determine the maximum size of cue based on its starting position 1767 // and the direction in which it grows. 1768 final int maximumSize; 1769 switch (absAlignment) { 1770 case TextTrackCue.ALIGNMENT_LEFT: 1771 maximumSize = 100 - cue.mTextPosition; 1772 break; 1773 case TextTrackCue.ALIGNMENT_RIGHT: 1774 maximumSize = cue.mTextPosition; 1775 break; 1776 case TextTrackCue.ALIGNMENT_MIDDLE: 1777 if (cue.mTextPosition <= 50) { 1778 maximumSize = cue.mTextPosition * 2; 1779 } else { 1780 maximumSize = (100 - cue.mTextPosition) * 2; 1781 } 1782 break; 1783 default: 1784 maximumSize = 0; 1785 } 1786 1787 // Determine absolute maximum cue size as the smaller of the 1788 // requested size and the maximum theoretical size. 1789 final int size = Math.min(cue.mSize, maximumSize) * specWidth / 100; 1790 widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST); 1791 heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST); 1792 measure(widthMeasureSpec, heightMeasureSpec); 1793 } 1794 1795 /** 1796 * Sets the order of this cue in the list of active cues. 1797 * 1798 * @param order the order of this cue in the list of active cues 1799 */ 1800 public void setOrder(int order) { 1801 mOrder = order; 1802 } 1803 1804 /** 1805 * @return whether this cue is marked as active 1806 */ 1807 public boolean isActive() { 1808 return mActive; 1809 } 1810 1811 /** 1812 * @return the cue data backing this layout 1813 */ 1814 public TextTrackCue getCue() { 1815 return mCue; 1816 } 1817 } 1818 1819 /** 1820 * A text track line represents a single line of text within a cue. 1821 * <p> 1822 * A single line may contain multiple spans, each representing a section of 1823 * text that may be enabled or disabled at a particular time. 1824 */ 1825 private static class SpanLayout extends SubtitleView { 1826 private final SpannableStringBuilder mBuilder = new SpannableStringBuilder(); 1827 private final TextTrackCueSpan[] mSpans; 1828 1829 public SpanLayout(Context context, TextTrackCueSpan[] spans) { 1830 super(context); 1831 1832 mSpans = spans; 1833 1834 update(); 1835 } 1836 1837 public void update() { 1838 final SpannableStringBuilder builder = mBuilder; 1839 final TextTrackCueSpan[] spans = mSpans; 1840 1841 builder.clear(); 1842 builder.clearSpans(); 1843 1844 final int spanCount = spans.length; 1845 for (int i = 0; i < spanCount; i++) { 1846 final TextTrackCueSpan span = spans[i]; 1847 if (span.mEnabled) { 1848 builder.append(spans[i].mText); 1849 } 1850 } 1851 1852 setText(builder); 1853 } 1854 1855 public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1856 setBackgroundColor(captionStyle.backgroundColor); 1857 setForegroundColor(captionStyle.foregroundColor); 1858 setEdgeColor(captionStyle.edgeColor); 1859 setEdgeType(captionStyle.edgeType); 1860 setTypeface(captionStyle.getTypeface()); 1861 setTextSize(fontSize); 1862 } 1863 } 1864} 1865