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