WebVttRenderer.java revision d6479ec5eec13914f656f6be996d95fe1610fd57
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.equals("WEBVTT") && 694 !line.startsWith("WEBVTT ") && 695 !line.startsWith("WEBVTT\t")) { 696 log_warning("Not a WEBVTT header", line); 697 mPhase = mSkipRest; 698 } else { 699 mPhase = mParseHeader; 700 } 701 } 702 }; 703 704 final private Phase mParseHeader = new Phase() { // 10-13 705 TextTrackRegion parseRegion(String s) { 706 TextTrackRegion region = new TextTrackRegion(); 707 for (String setting: s.split(" +")) { 708 int equalAt = setting.indexOf('='); 709 if (equalAt <= 0 || equalAt == setting.length() - 1) { 710 continue; 711 } 712 713 String name = setting.substring(0, equalAt); 714 String value = setting.substring(equalAt + 1); 715 if (name.equals("id")) { 716 region.mId = value; 717 } else if (name.equals("width")) { 718 try { 719 region.mWidth = parseFloatPercentage(value); 720 } catch (NumberFormatException e) { 721 log_warning("region setting", name, 722 "has invalid value", e.getMessage(), value); 723 } 724 } else if (name.equals("lines")) { 725 try { 726 int lines = Integer.parseInt(value); 727 if (lines >= 0) { 728 region.mLines = lines; 729 } else { 730 log_warning("region setting", name, "is negative", value); 731 } 732 } catch (NumberFormatException e) { 733 log_warning("region setting", name, "is not numeric", value); 734 } 735 } else if (name.equals("regionanchor") || 736 name.equals("viewportanchor")) { 737 int commaAt = value.indexOf(","); 738 if (commaAt < 0) { 739 log_warning("region setting", name, "contains no comma", value); 740 continue; 741 } 742 743 String anchorX = value.substring(0, commaAt); 744 String anchorY = value.substring(commaAt + 1); 745 float x, y; 746 747 try { 748 x = parseFloatPercentage(anchorX); 749 } catch (NumberFormatException e) { 750 log_warning("region setting", name, 751 "has invalid x component", e.getMessage(), anchorX); 752 continue; 753 } 754 try { 755 y = parseFloatPercentage(anchorY); 756 } catch (NumberFormatException e) { 757 log_warning("region setting", name, 758 "has invalid y component", e.getMessage(), anchorY); 759 continue; 760 } 761 762 if (name.charAt(0) == 'r') { 763 region.mAnchorPointX = x; 764 region.mAnchorPointY = y; 765 } else { 766 region.mViewportAnchorPointX = x; 767 region.mViewportAnchorPointY = y; 768 } 769 } else if (name.equals("scroll")) { 770 if (value.equals("up")) { 771 region.mScrollValue = 772 TextTrackRegion.SCROLL_VALUE_SCROLL_UP; 773 } else { 774 log_warning("region setting", name, "has invalid value", value); 775 } 776 } 777 } 778 return region; 779 } 780 781 @Override 782 public void parse(String line) { 783 if (line.length() == 0) { 784 mPhase = mParseCueId; 785 } else if (line.contains("-->")) { 786 mPhase = mParseCueTime; 787 mPhase.parse(line); 788 } else { 789 int colonAt = line.indexOf(':'); 790 if (colonAt <= 0 || colonAt >= line.length() - 1) { 791 log_warning("meta data header has invalid format", line); 792 } 793 String name = line.substring(0, colonAt); 794 String value = line.substring(colonAt + 1); 795 796 if (name.equals("Region")) { 797 TextTrackRegion region = parseRegion(value); 798 mListener.onRegionParsed(region); 799 } 800 } 801 } 802 }; 803 804 final private Phase mParseCueId = new Phase() { 805 @Override 806 public void parse(String line) { 807 if (line.length() == 0) { 808 return; 809 } 810 811 assert(mCue == null); 812 813 if (line.equals("NOTE") || line.startsWith("NOTE ")) { 814 mPhase = mParseCueText; 815 } 816 817 mCue = new TextTrackCue(); 818 mCueTexts.clear(); 819 820 mPhase = mParseCueTime; 821 if (line.contains("-->")) { 822 mPhase.parse(line); 823 } else { 824 mCue.mId = line; 825 } 826 } 827 }; 828 829 final private Phase mParseCueTime = new Phase() { 830 @Override 831 public void parse(String line) { 832 int arrowAt = line.indexOf("-->"); 833 if (arrowAt < 0) { 834 mCue = null; 835 mPhase = mParseCueId; 836 return; 837 } 838 839 String start = line.substring(0, arrowAt).trim(); 840 // convert only initial and first other white-space to space 841 String rest = line.substring(arrowAt + 3) 842 .replaceFirst("^\\s+", "").replaceFirst("\\s+", " "); 843 int spaceAt = rest.indexOf(' '); 844 String end = spaceAt > 0 ? rest.substring(0, spaceAt) : rest; 845 rest = spaceAt > 0 ? rest.substring(spaceAt + 1) : ""; 846 847 mCue.mStartTimeMs = parseTimestampMs(start); 848 mCue.mEndTimeMs = parseTimestampMs(end); 849 for (String setting: rest.split(" +")) { 850 int colonAt = setting.indexOf(':'); 851 if (colonAt <= 0 || colonAt == setting.length() - 1) { 852 continue; 853 } 854 String name = setting.substring(0, colonAt); 855 String value = setting.substring(colonAt + 1); 856 857 if (name.equals("region")) { 858 mCue.mRegionId = value; 859 } else if (name.equals("vertical")) { 860 if (value.equals("rl")) { 861 mCue.mWritingDirection = 862 TextTrackCue.WRITING_DIRECTION_VERTICAL_RL; 863 } else if (value.equals("lr")) { 864 mCue.mWritingDirection = 865 TextTrackCue.WRITING_DIRECTION_VERTICAL_LR; 866 } else { 867 log_warning("cue setting", name, "has invalid value", value); 868 } 869 } else if (name.equals("line")) { 870 try { 871 int linePosition; 872 /* TRICKY: we know that there are no spaces in value */ 873 assert(value.indexOf(' ') < 0); 874 if (value.endsWith("%")) { 875 linePosition = Integer.parseInt( 876 value.substring(0, value.length() - 1)); 877 if (linePosition < 0 || linePosition > 100) { 878 log_warning("cue setting", name, "is out of range", value); 879 continue; 880 } 881 mCue.mSnapToLines = false; 882 mCue.mLinePosition = linePosition; 883 } else { 884 mCue.mSnapToLines = true; 885 mCue.mLinePosition = Integer.parseInt(value); 886 } 887 } catch (NumberFormatException e) { 888 log_warning("cue setting", name, 889 "is not numeric or percentage", value); 890 } 891 } else if (name.equals("position")) { 892 try { 893 mCue.mTextPosition = parseIntPercentage(value); 894 } catch (NumberFormatException e) { 895 log_warning("cue setting", name, 896 "is not numeric or percentage", value); 897 } 898 } else if (name.equals("size")) { 899 try { 900 mCue.mSize = parseIntPercentage(value); 901 } catch (NumberFormatException e) { 902 log_warning("cue setting", name, 903 "is not numeric or percentage", value); 904 } 905 } else if (name.equals("align")) { 906 if (value.equals("start")) { 907 mCue.mAlignment = TextTrackCue.ALIGNMENT_START; 908 } else if (value.equals("middle")) { 909 mCue.mAlignment = TextTrackCue.ALIGNMENT_MIDDLE; 910 } else if (value.equals("end")) { 911 mCue.mAlignment = TextTrackCue.ALIGNMENT_END; 912 } else if (value.equals("left")) { 913 mCue.mAlignment = TextTrackCue.ALIGNMENT_LEFT; 914 } else if (value.equals("right")) { 915 mCue.mAlignment = TextTrackCue.ALIGNMENT_RIGHT; 916 } else { 917 log_warning("cue setting", name, "has invalid value", value); 918 continue; 919 } 920 } 921 } 922 923 if (mCue.mLinePosition != null || 924 mCue.mSize != 100 || 925 (mCue.mWritingDirection != 926 TextTrackCue.WRITING_DIRECTION_HORIZONTAL)) { 927 mCue.mRegionId = ""; 928 } 929 930 mPhase = mParseCueText; 931 } 932 }; 933 934 /* also used for notes */ 935 final private Phase mParseCueText = new Phase() { 936 @Override 937 public void parse(String line) { 938 if (line.length() == 0) { 939 yieldCue(); 940 mPhase = mParseCueId; 941 return; 942 } else if (mCue != null) { 943 mCueTexts.add(line); 944 } 945 } 946 }; 947 948 private void log_warning( 949 String nameType, String name, String message, 950 String subMessage, String value) { 951 Log.w(this.getClass().getName(), nameType + " '" + name + "' " + 952 message + " ('" + value + "' " + subMessage + ")"); 953 } 954 955 private void log_warning( 956 String nameType, String name, String message, String value) { 957 Log.w(this.getClass().getName(), nameType + " '" + name + "' " + 958 message + " ('" + value + "')"); 959 } 960 961 private void log_warning(String message, String value) { 962 Log.w(this.getClass().getName(), message + " ('" + value + "')"); 963 } 964} 965 966/** @hide */ 967interface WebVttCueListener { 968 void onCueParsed(TextTrackCue cue); 969 void onRegionParsed(TextTrackRegion region); 970} 971 972/** @hide */ 973class WebVttTrack extends SubtitleTrack implements WebVttCueListener { 974 private static final String TAG = "WebVttTrack"; 975 976 private final WebVttParser mParser = new WebVttParser(this); 977 private final UnstyledTextExtractor mExtractor = 978 new UnstyledTextExtractor(); 979 private final Tokenizer mTokenizer = new Tokenizer(mExtractor); 980 private final Vector<Long> mTimestamps = new Vector<Long>(); 981 private final WebVttRenderingWidget mRenderingWidget; 982 983 private final Map<String, TextTrackRegion> mRegions = 984 new HashMap<String, TextTrackRegion>(); 985 private Long mCurrentRunID; 986 987 WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format) { 988 super(format); 989 990 mRenderingWidget = renderingWidget; 991 } 992 993 @Override 994 public WebVttRenderingWidget getRenderingWidget() { 995 return mRenderingWidget; 996 } 997 998 @Override 999 public void onData(String data, boolean eos, long runID) { 1000 // implement intermixing restriction for WebVTT only for now 1001 synchronized(mParser) { 1002 if (mCurrentRunID != null && runID != mCurrentRunID) { 1003 throw new IllegalStateException( 1004 "Run #" + mCurrentRunID + 1005 " in progress. Cannot process run #" + runID); 1006 } 1007 mCurrentRunID = runID; 1008 mParser.parse(data); 1009 if (eos) { 1010 finishedRun(runID); 1011 mParser.eos(); 1012 mRegions.clear(); 1013 mCurrentRunID = null; 1014 } 1015 } 1016 } 1017 1018 @Override 1019 public void onCueParsed(TextTrackCue cue) { 1020 synchronized (mParser) { 1021 // resolve region 1022 if (cue.mRegionId.length() != 0) { 1023 cue.mRegion = mRegions.get(cue.mRegionId); 1024 } 1025 1026 if (DEBUG) Log.v(TAG, "adding cue " + cue); 1027 1028 // tokenize text track string-lines into lines of spans 1029 mTokenizer.reset(); 1030 for (String s: cue.mStrings) { 1031 mTokenizer.tokenize(s); 1032 } 1033 cue.mLines = mExtractor.getText(); 1034 if (DEBUG) Log.v(TAG, cue.appendLinesToBuilder( 1035 cue.appendStringsToBuilder( 1036 new StringBuilder()).append(" simplified to: ")) 1037 .toString()); 1038 1039 // extract inner timestamps 1040 for (TextTrackCueSpan[] line: cue.mLines) { 1041 for (TextTrackCueSpan span: line) { 1042 if (span.mTimestampMs > cue.mStartTimeMs && 1043 span.mTimestampMs < cue.mEndTimeMs && 1044 !mTimestamps.contains(span.mTimestampMs)) { 1045 mTimestamps.add(span.mTimestampMs); 1046 } 1047 } 1048 } 1049 1050 if (mTimestamps.size() > 0) { 1051 cue.mInnerTimesMs = new long[mTimestamps.size()]; 1052 for (int ix=0; ix < mTimestamps.size(); ++ix) { 1053 cue.mInnerTimesMs[ix] = mTimestamps.get(ix); 1054 } 1055 mTimestamps.clear(); 1056 } else { 1057 cue.mInnerTimesMs = null; 1058 } 1059 1060 cue.mRunID = mCurrentRunID; 1061 } 1062 1063 addCue(cue); 1064 } 1065 1066 @Override 1067 public void onRegionParsed(TextTrackRegion region) { 1068 synchronized(mParser) { 1069 mRegions.put(region.mId, region); 1070 } 1071 } 1072 1073 @Override 1074 public void updateView(Vector<SubtitleTrack.Cue> activeCues) { 1075 if (!mVisible) { 1076 // don't keep the state if we are not visible 1077 return; 1078 } 1079 1080 if (DEBUG && mTimeProvider != null) { 1081 try { 1082 Log.d(TAG, "at " + 1083 (mTimeProvider.getCurrentTimeUs(false, true) / 1000) + 1084 " ms the active cues are:"); 1085 } catch (IllegalStateException e) { 1086 Log.d(TAG, "at (illegal state) the active cues are:"); 1087 } 1088 } 1089 1090 mRenderingWidget.setActiveCues(activeCues); 1091 } 1092} 1093 1094/** 1095 * Widget capable of rendering WebVTT captions. 1096 * 1097 * @hide 1098 */ 1099class WebVttRenderingWidget extends ViewGroup implements SubtitleTrack.RenderingWidget { 1100 private static final boolean DEBUG = false; 1101 private static final int DEBUG_REGION_BACKGROUND = 0x800000FF; 1102 private static final int DEBUG_CUE_BACKGROUND = 0x80FF0000; 1103 1104 /** WebVtt specifies line height as 5.3% of the viewport height. */ 1105 private static final float LINE_HEIGHT_RATIO = 0.0533f; 1106 1107 /** Map of active regions, used to determine enter/exit. */ 1108 private final ArrayMap<TextTrackRegion, RegionLayout> mRegionBoxes = 1109 new ArrayMap<TextTrackRegion, RegionLayout>(); 1110 1111 /** Map of active cues, used to determine enter/exit. */ 1112 private final ArrayMap<TextTrackCue, CueLayout> mCueBoxes = 1113 new ArrayMap<TextTrackCue, CueLayout>(); 1114 1115 /** Captioning manager, used to obtain and track caption properties. */ 1116 private final CaptioningManager mManager; 1117 1118 /** Callback for rendering changes. */ 1119 private OnChangedListener mListener; 1120 1121 /** Current caption style. */ 1122 private CaptionStyle mCaptionStyle; 1123 1124 /** Current font size, computed from font scaling factor and height. */ 1125 private float mFontSize; 1126 1127 /** Whether a caption style change listener is registered. */ 1128 private boolean mHasChangeListener; 1129 1130 public WebVttRenderingWidget(Context context) { 1131 this(context, null); 1132 } 1133 1134 public WebVttRenderingWidget(Context context, AttributeSet attrs) { 1135 this(context, attrs, 0); 1136 } 1137 1138 public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr) { 1139 this(context, attrs, defStyleAttr, 0); 1140 } 1141 1142 public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { 1143 super(context, attrs, defStyleAttr, defStyleRes); 1144 1145 // Cannot render text over video when layer type is hardware. 1146 setLayerType(View.LAYER_TYPE_SOFTWARE, null); 1147 1148 mManager = (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); 1149 mCaptionStyle = mManager.getUserStyle(); 1150 mFontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO; 1151 } 1152 1153 @Override 1154 public void setSize(int width, int height) { 1155 final int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY); 1156 final int heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY); 1157 1158 measure(widthSpec, heightSpec); 1159 layout(0, 0, width, height); 1160 } 1161 1162 @Override 1163 public void onAttachedToWindow() { 1164 super.onAttachedToWindow(); 1165 1166 manageChangeListener(); 1167 } 1168 1169 @Override 1170 public void onDetachedFromWindow() { 1171 super.onDetachedFromWindow(); 1172 1173 manageChangeListener(); 1174 } 1175 1176 @Override 1177 public void setOnChangedListener(OnChangedListener listener) { 1178 mListener = listener; 1179 } 1180 1181 @Override 1182 public void setVisible(boolean visible) { 1183 if (visible) { 1184 setVisibility(View.VISIBLE); 1185 } else { 1186 setVisibility(View.GONE); 1187 } 1188 1189 manageChangeListener(); 1190 } 1191 1192 /** 1193 * Manages whether this renderer is listening for caption style changes. 1194 */ 1195 private void manageChangeListener() { 1196 final boolean needsListener = isAttachedToWindow() && getVisibility() == View.VISIBLE; 1197 if (mHasChangeListener != needsListener) { 1198 mHasChangeListener = needsListener; 1199 1200 if (needsListener) { 1201 mManager.addCaptioningChangeListener(mCaptioningListener); 1202 1203 final CaptionStyle captionStyle = mManager.getUserStyle(); 1204 final float fontSize = mManager.getFontScale() * getHeight() * LINE_HEIGHT_RATIO; 1205 setCaptionStyle(captionStyle, fontSize); 1206 } else { 1207 mManager.removeCaptioningChangeListener(mCaptioningListener); 1208 } 1209 } 1210 } 1211 1212 public void setActiveCues(Vector<SubtitleTrack.Cue> activeCues) { 1213 final Context context = getContext(); 1214 final CaptionStyle captionStyle = mCaptionStyle; 1215 final float fontSize = mFontSize; 1216 1217 prepForPrune(); 1218 1219 // Ensure we have all necessary cue and region boxes. 1220 final int count = activeCues.size(); 1221 for (int i = 0; i < count; i++) { 1222 final TextTrackCue cue = (TextTrackCue) activeCues.get(i); 1223 final TextTrackRegion region = cue.mRegion; 1224 if (region != null) { 1225 RegionLayout regionBox = mRegionBoxes.get(region); 1226 if (regionBox == null) { 1227 regionBox = new RegionLayout(context, region, captionStyle, fontSize); 1228 mRegionBoxes.put(region, regionBox); 1229 addView(regionBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1230 } 1231 regionBox.put(cue); 1232 } else { 1233 CueLayout cueBox = mCueBoxes.get(cue); 1234 if (cueBox == null) { 1235 cueBox = new CueLayout(context, cue, captionStyle, fontSize); 1236 mCueBoxes.put(cue, cueBox); 1237 addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1238 } 1239 cueBox.update(); 1240 cueBox.setOrder(i); 1241 } 1242 } 1243 1244 prune(); 1245 1246 // Force measurement and layout. 1247 final int width = getWidth(); 1248 final int height = getHeight(); 1249 setSize(width, height); 1250 1251 if (mListener != null) { 1252 mListener.onChanged(this); 1253 } 1254 } 1255 1256 private void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1257 mCaptionStyle = captionStyle; 1258 mFontSize = fontSize; 1259 1260 final int cueCount = mCueBoxes.size(); 1261 for (int i = 0; i < cueCount; i++) { 1262 final CueLayout cueBox = mCueBoxes.valueAt(i); 1263 cueBox.setCaptionStyle(captionStyle, fontSize); 1264 } 1265 1266 final int regionCount = mRegionBoxes.size(); 1267 for (int i = 0; i < regionCount; i++) { 1268 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1269 regionBox.setCaptionStyle(captionStyle, fontSize); 1270 } 1271 } 1272 1273 /** 1274 * Remove inactive cues and regions. 1275 */ 1276 private void prune() { 1277 int regionCount = mRegionBoxes.size(); 1278 for (int i = 0; i < regionCount; i++) { 1279 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1280 if (regionBox.prune()) { 1281 removeView(regionBox); 1282 mRegionBoxes.removeAt(i); 1283 regionCount--; 1284 i--; 1285 } 1286 } 1287 1288 int cueCount = mCueBoxes.size(); 1289 for (int i = 0; i < cueCount; i++) { 1290 final CueLayout cueBox = mCueBoxes.valueAt(i); 1291 if (!cueBox.isActive()) { 1292 removeView(cueBox); 1293 mCueBoxes.removeAt(i); 1294 cueCount--; 1295 i--; 1296 } 1297 } 1298 } 1299 1300 /** 1301 * Reset active cues and regions. 1302 */ 1303 private void prepForPrune() { 1304 final int regionCount = mRegionBoxes.size(); 1305 for (int i = 0; i < regionCount; i++) { 1306 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1307 regionBox.prepForPrune(); 1308 } 1309 1310 final int cueCount = mCueBoxes.size(); 1311 for (int i = 0; i < cueCount; i++) { 1312 final CueLayout cueBox = mCueBoxes.valueAt(i); 1313 cueBox.prepForPrune(); 1314 } 1315 } 1316 1317 @Override 1318 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1319 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1320 1321 final int regionCount = mRegionBoxes.size(); 1322 for (int i = 0; i < regionCount; i++) { 1323 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1324 regionBox.measureForParent(widthMeasureSpec, heightMeasureSpec); 1325 } 1326 1327 final int cueCount = mCueBoxes.size(); 1328 for (int i = 0; i < cueCount; i++) { 1329 final CueLayout cueBox = mCueBoxes.valueAt(i); 1330 cueBox.measureForParent(widthMeasureSpec, heightMeasureSpec); 1331 } 1332 } 1333 1334 @Override 1335 protected void onLayout(boolean changed, int l, int t, int r, int b) { 1336 final int viewportWidth = r - l; 1337 final int viewportHeight = b - t; 1338 1339 setCaptionStyle(mCaptionStyle, 1340 mManager.getFontScale() * LINE_HEIGHT_RATIO * viewportHeight); 1341 1342 final int regionCount = mRegionBoxes.size(); 1343 for (int i = 0; i < regionCount; i++) { 1344 final RegionLayout regionBox = mRegionBoxes.valueAt(i); 1345 layoutRegion(viewportWidth, viewportHeight, regionBox); 1346 } 1347 1348 final int cueCount = mCueBoxes.size(); 1349 for (int i = 0; i < cueCount; i++) { 1350 final CueLayout cueBox = mCueBoxes.valueAt(i); 1351 layoutCue(viewportWidth, viewportHeight, cueBox); 1352 } 1353 } 1354 1355 /** 1356 * Lays out a region within the viewport. The region handles layout for 1357 * contained cues. 1358 */ 1359 private void layoutRegion( 1360 int viewportWidth, int viewportHeight, 1361 RegionLayout regionBox) { 1362 final TextTrackRegion region = regionBox.getRegion(); 1363 final int regionHeight = regionBox.getMeasuredHeight(); 1364 final int regionWidth = regionBox.getMeasuredWidth(); 1365 1366 // TODO: Account for region anchor point. 1367 final float x = region.mViewportAnchorPointX; 1368 final float y = region.mViewportAnchorPointY; 1369 final int left = (int) (x * (viewportWidth - regionWidth) / 100); 1370 final int top = (int) (y * (viewportHeight - regionHeight) / 100); 1371 1372 regionBox.layout(left, top, left + regionWidth, top + regionHeight); 1373 } 1374 1375 /** 1376 * Lays out a cue within the viewport. 1377 */ 1378 private void layoutCue( 1379 int viewportWidth, int viewportHeight, CueLayout cueBox) { 1380 final TextTrackCue cue = cueBox.getCue(); 1381 final int direction = getLayoutDirection(); 1382 final int absAlignment = resolveCueAlignment(direction, cue.mAlignment); 1383 final boolean cueSnapToLines = cue.mSnapToLines; 1384 1385 int size = 100 * cueBox.getMeasuredWidth() / viewportWidth; 1386 1387 // Determine raw x-position. 1388 int xPosition; 1389 switch (absAlignment) { 1390 case TextTrackCue.ALIGNMENT_LEFT: 1391 xPosition = cue.mTextPosition; 1392 break; 1393 case TextTrackCue.ALIGNMENT_RIGHT: 1394 xPosition = cue.mTextPosition - size; 1395 break; 1396 case TextTrackCue.ALIGNMENT_MIDDLE: 1397 default: 1398 xPosition = cue.mTextPosition - size / 2; 1399 break; 1400 } 1401 1402 // Adjust x-position for layout. 1403 if (direction == LAYOUT_DIRECTION_RTL) { 1404 xPosition = 100 - xPosition; 1405 } 1406 1407 // If the text track cue snap-to-lines flag is set, adjust 1408 // x-position and size for padding. This is equivalent to placing the 1409 // cue within the title-safe area. 1410 if (cueSnapToLines) { 1411 final int paddingLeft = 100 * getPaddingLeft() / viewportWidth; 1412 final int paddingRight = 100 * getPaddingRight() / viewportWidth; 1413 if (xPosition < paddingLeft && xPosition + size > paddingLeft) { 1414 xPosition += paddingLeft; 1415 size -= paddingLeft; 1416 } 1417 final float rightEdge = 100 - paddingRight; 1418 if (xPosition < rightEdge && xPosition + size > rightEdge) { 1419 size -= paddingRight; 1420 } 1421 } 1422 1423 // Compute absolute left position and width. 1424 final int left = xPosition * viewportWidth / 100; 1425 final int width = size * viewportWidth / 100; 1426 1427 // Determine initial y-position. 1428 final int yPosition = calculateLinePosition(cueBox); 1429 1430 // Compute absolute final top position and height. 1431 final int height = cueBox.getMeasuredHeight(); 1432 final int top; 1433 if (yPosition < 0) { 1434 // TODO: This needs to use the actual height of prior boxes. 1435 top = viewportHeight + yPosition * height; 1436 } else { 1437 top = yPosition * (viewportHeight - height) / 100; 1438 } 1439 1440 // Layout cue in final position. 1441 cueBox.layout(left, top, left + width, top + height); 1442 } 1443 1444 /** 1445 * Calculates the line position for a cue. 1446 * <p> 1447 * If the resulting position is negative, it represents a bottom-aligned 1448 * position relative to the number of active cues. Otherwise, it represents 1449 * a percentage [0-100] of the viewport height. 1450 */ 1451 private int calculateLinePosition(CueLayout cueBox) { 1452 final TextTrackCue cue = cueBox.getCue(); 1453 final Integer linePosition = cue.mLinePosition; 1454 final boolean snapToLines = cue.mSnapToLines; 1455 final boolean autoPosition = (linePosition == null); 1456 1457 if (!snapToLines && !autoPosition && (linePosition < 0 || linePosition > 100)) { 1458 // Invalid line position defaults to 100. 1459 return 100; 1460 } else if (!autoPosition) { 1461 // Use the valid, supplied line position. 1462 return linePosition; 1463 } else if (!snapToLines) { 1464 // Automatic, non-snapped line position defaults to 100. 1465 return 100; 1466 } else { 1467 // Automatic snapped line position uses active cue order. 1468 return -(cueBox.mOrder + 1); 1469 } 1470 } 1471 1472 /** 1473 * Resolves cue alignment according to the specified layout direction. 1474 */ 1475 private static int resolveCueAlignment(int layoutDirection, int alignment) { 1476 switch (alignment) { 1477 case TextTrackCue.ALIGNMENT_START: 1478 return layoutDirection == View.LAYOUT_DIRECTION_LTR ? 1479 TextTrackCue.ALIGNMENT_LEFT : TextTrackCue.ALIGNMENT_RIGHT; 1480 case TextTrackCue.ALIGNMENT_END: 1481 return layoutDirection == View.LAYOUT_DIRECTION_LTR ? 1482 TextTrackCue.ALIGNMENT_RIGHT : TextTrackCue.ALIGNMENT_LEFT; 1483 } 1484 return alignment; 1485 } 1486 1487 private final CaptioningChangeListener mCaptioningListener = new CaptioningChangeListener() { 1488 @Override 1489 public void onFontScaleChanged(float fontScale) { 1490 final float fontSize = fontScale * getHeight() * LINE_HEIGHT_RATIO; 1491 setCaptionStyle(mCaptionStyle, fontSize); 1492 } 1493 1494 @Override 1495 public void onUserStyleChanged(CaptionStyle userStyle) { 1496 setCaptionStyle(userStyle, mFontSize); 1497 } 1498 }; 1499 1500 /** 1501 * A text track region represents a portion of the video viewport and 1502 * provides a rendering area for text track cues. 1503 */ 1504 private static class RegionLayout extends LinearLayout { 1505 private final ArrayList<CueLayout> mRegionCueBoxes = new ArrayList<CueLayout>(); 1506 private final TextTrackRegion mRegion; 1507 1508 private CaptionStyle mCaptionStyle; 1509 private float mFontSize; 1510 1511 public RegionLayout(Context context, TextTrackRegion region, CaptionStyle captionStyle, 1512 float fontSize) { 1513 super(context); 1514 1515 mRegion = region; 1516 mCaptionStyle = captionStyle; 1517 mFontSize = fontSize; 1518 1519 // TODO: Add support for vertical text 1520 setOrientation(VERTICAL); 1521 1522 if (DEBUG) { 1523 setBackgroundColor(DEBUG_REGION_BACKGROUND); 1524 } 1525 } 1526 1527 public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1528 mCaptionStyle = captionStyle; 1529 mFontSize = fontSize; 1530 1531 final int cueCount = mRegionCueBoxes.size(); 1532 for (int i = 0; i < cueCount; i++) { 1533 final CueLayout cueBox = mRegionCueBoxes.get(i); 1534 cueBox.setCaptionStyle(captionStyle, fontSize); 1535 } 1536 } 1537 1538 /** 1539 * Performs the parent's measurement responsibilities, then 1540 * automatically performs its own measurement. 1541 */ 1542 public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) { 1543 final TextTrackRegion region = mRegion; 1544 final int specWidth = MeasureSpec.getSize(widthMeasureSpec); 1545 final int specHeight = MeasureSpec.getSize(heightMeasureSpec); 1546 final int width = (int) region.mWidth; 1547 1548 // Determine the absolute maximum region size as the requested size. 1549 final int size = width * specWidth / 100; 1550 1551 widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST); 1552 heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST); 1553 measure(widthMeasureSpec, heightMeasureSpec); 1554 } 1555 1556 /** 1557 * Prepares this region for pruning by setting all tracks as inactive. 1558 * <p> 1559 * Tracks that are added or updated using {@link #put(TextTrackCue)} 1560 * after this calling this method will be marked as active. 1561 */ 1562 public void prepForPrune() { 1563 final int cueCount = mRegionCueBoxes.size(); 1564 for (int i = 0; i < cueCount; i++) { 1565 final CueLayout cueBox = mRegionCueBoxes.get(i); 1566 cueBox.prepForPrune(); 1567 } 1568 } 1569 1570 /** 1571 * Adds a {@link TextTrackCue} to this region. If the track had already 1572 * been added, updates its active state. 1573 * 1574 * @param cue 1575 */ 1576 public void put(TextTrackCue cue) { 1577 final int cueCount = mRegionCueBoxes.size(); 1578 for (int i = 0; i < cueCount; i++) { 1579 final CueLayout cueBox = mRegionCueBoxes.get(i); 1580 if (cueBox.getCue() == cue) { 1581 cueBox.update(); 1582 return; 1583 } 1584 } 1585 1586 final CueLayout cueBox = new CueLayout(getContext(), cue, mCaptionStyle, mFontSize); 1587 addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1588 1589 if (getChildCount() > mRegion.mLines) { 1590 removeViewAt(0); 1591 } 1592 } 1593 1594 /** 1595 * Remove all inactive tracks from this region. 1596 * 1597 * @return true if this region is empty and should be pruned 1598 */ 1599 public boolean prune() { 1600 int cueCount = mRegionCueBoxes.size(); 1601 for (int i = 0; i < cueCount; i++) { 1602 final CueLayout cueBox = mRegionCueBoxes.get(i); 1603 if (!cueBox.isActive()) { 1604 mRegionCueBoxes.remove(i); 1605 removeView(cueBox); 1606 cueCount--; 1607 i--; 1608 } 1609 } 1610 1611 return mRegionCueBoxes.isEmpty(); 1612 } 1613 1614 /** 1615 * @return the region data backing this layout 1616 */ 1617 public TextTrackRegion getRegion() { 1618 return mRegion; 1619 } 1620 } 1621 1622 /** 1623 * A text track cue is the unit of time-sensitive data in a text track, 1624 * corresponding for instance for subtitles and captions to the text that 1625 * appears at a particular time and disappears at another time. 1626 * <p> 1627 * A single cue may contain multiple {@link SpanLayout}s, each representing a 1628 * single line of text. 1629 */ 1630 private static class CueLayout extends LinearLayout { 1631 public final TextTrackCue mCue; 1632 1633 private CaptionStyle mCaptionStyle; 1634 private float mFontSize; 1635 1636 private boolean mActive; 1637 private int mOrder; 1638 1639 public CueLayout( 1640 Context context, TextTrackCue cue, CaptionStyle captionStyle, float fontSize) { 1641 super(context); 1642 1643 mCue = cue; 1644 mCaptionStyle = captionStyle; 1645 mFontSize = fontSize; 1646 1647 // TODO: Add support for vertical text. 1648 final boolean horizontal = cue.mWritingDirection 1649 == TextTrackCue.WRITING_DIRECTION_HORIZONTAL; 1650 setOrientation(horizontal ? VERTICAL : HORIZONTAL); 1651 1652 switch (cue.mAlignment) { 1653 case TextTrackCue.ALIGNMENT_END: 1654 setGravity(Gravity.END); 1655 break; 1656 case TextTrackCue.ALIGNMENT_LEFT: 1657 setGravity(Gravity.LEFT); 1658 break; 1659 case TextTrackCue.ALIGNMENT_MIDDLE: 1660 setGravity(horizontal 1661 ? Gravity.CENTER_HORIZONTAL : Gravity.CENTER_VERTICAL); 1662 break; 1663 case TextTrackCue.ALIGNMENT_RIGHT: 1664 setGravity(Gravity.RIGHT); 1665 break; 1666 case TextTrackCue.ALIGNMENT_START: 1667 setGravity(Gravity.START); 1668 break; 1669 } 1670 1671 if (DEBUG) { 1672 setBackgroundColor(DEBUG_CUE_BACKGROUND); 1673 } 1674 1675 update(); 1676 } 1677 1678 public void setCaptionStyle(CaptionStyle style, float fontSize) { 1679 mCaptionStyle = style; 1680 mFontSize = fontSize; 1681 1682 final int n = getChildCount(); 1683 for (int i = 0; i < n; i++) { 1684 final View child = getChildAt(i); 1685 if (child instanceof SpanLayout) { 1686 ((SpanLayout) child).setCaptionStyle(style, fontSize); 1687 } 1688 } 1689 } 1690 1691 public void prepForPrune() { 1692 mActive = false; 1693 } 1694 1695 public void update() { 1696 mActive = true; 1697 1698 removeAllViews(); 1699 1700 final CaptionStyle captionStyle = mCaptionStyle; 1701 final float fontSize = mFontSize; 1702 final TextTrackCueSpan[][] lines = mCue.mLines; 1703 final int lineCount = lines.length; 1704 for (int i = 0; i < lineCount; i++) { 1705 final SpanLayout lineBox = new SpanLayout(getContext(), lines[i]); 1706 lineBox.setCaptionStyle(captionStyle, fontSize); 1707 1708 addView(lineBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 1709 } 1710 } 1711 1712 @Override 1713 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 1714 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 1715 } 1716 1717 /** 1718 * Performs the parent's measurement responsibilities, then 1719 * automatically performs its own measurement. 1720 */ 1721 public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) { 1722 final TextTrackCue cue = mCue; 1723 final int specWidth = MeasureSpec.getSize(widthMeasureSpec); 1724 final int specHeight = MeasureSpec.getSize(heightMeasureSpec); 1725 final int direction = getLayoutDirection(); 1726 final int absAlignment = resolveCueAlignment(direction, cue.mAlignment); 1727 1728 // Determine the maximum size of cue based on its starting position 1729 // and the direction in which it grows. 1730 final int maximumSize; 1731 switch (absAlignment) { 1732 case TextTrackCue.ALIGNMENT_LEFT: 1733 maximumSize = 100 - cue.mTextPosition; 1734 break; 1735 case TextTrackCue.ALIGNMENT_RIGHT: 1736 maximumSize = cue.mTextPosition; 1737 break; 1738 case TextTrackCue.ALIGNMENT_MIDDLE: 1739 if (cue.mTextPosition <= 50) { 1740 maximumSize = cue.mTextPosition * 2; 1741 } else { 1742 maximumSize = (100 - cue.mTextPosition) * 2; 1743 } 1744 break; 1745 default: 1746 maximumSize = 0; 1747 } 1748 1749 // Determine absolute maximum cue size as the smaller of the 1750 // requested size and the maximum theoretical size. 1751 final int size = Math.min(cue.mSize, maximumSize) * specWidth / 100; 1752 widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST); 1753 heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST); 1754 measure(widthMeasureSpec, heightMeasureSpec); 1755 } 1756 1757 /** 1758 * Sets the order of this cue in the list of active cues. 1759 * 1760 * @param order the order of this cue in the list of active cues 1761 */ 1762 public void setOrder(int order) { 1763 mOrder = order; 1764 } 1765 1766 /** 1767 * @return whether this cue is marked as active 1768 */ 1769 public boolean isActive() { 1770 return mActive; 1771 } 1772 1773 /** 1774 * @return the cue data backing this layout 1775 */ 1776 public TextTrackCue getCue() { 1777 return mCue; 1778 } 1779 } 1780 1781 /** 1782 * A text track line represents a single line of text within a cue. 1783 * <p> 1784 * A single line may contain multiple spans, each representing a section of 1785 * text that may be enabled or disabled at a particular time. 1786 */ 1787 private static class SpanLayout extends SubtitleView { 1788 private final SpannableStringBuilder mBuilder = new SpannableStringBuilder(); 1789 private final TextTrackCueSpan[] mSpans; 1790 1791 public SpanLayout(Context context, TextTrackCueSpan[] spans) { 1792 super(context); 1793 1794 mSpans = spans; 1795 1796 update(); 1797 } 1798 1799 public void update() { 1800 final SpannableStringBuilder builder = mBuilder; 1801 final TextTrackCueSpan[] spans = mSpans; 1802 1803 builder.clear(); 1804 builder.clearSpans(); 1805 1806 final int spanCount = spans.length; 1807 for (int i = 0; i < spanCount; i++) { 1808 final TextTrackCueSpan span = spans[i]; 1809 if (span.mEnabled) { 1810 builder.append(spans[i].mText); 1811 } 1812 } 1813 1814 setText(builder); 1815 } 1816 1817 public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) { 1818 setBackgroundColor(captionStyle.backgroundColor); 1819 setForegroundColor(captionStyle.foregroundColor); 1820 setEdgeColor(captionStyle.edgeColor); 1821 setEdgeType(captionStyle.edgeType); 1822 setTypeface(captionStyle.getTypeface()); 1823 setTextSize(fontSize); 1824 } 1825 } 1826} 1827