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