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