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