• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
WebVttRenderer(Context context)47     public WebVttRenderer(Context context) {
48         mContext = context;
49     }
50 
51     @Override
supports(MediaFormat format)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
createTrack(MediaFormat format)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;
TextTrackCueSpan(String text, long timestamp)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
equals(Object o)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 
UnstyledTextExtractor()103     UnstyledTextExtractor() {
104         init();
105     }
106 
init()107     private void init() {
108         mLine.delete(0, mLine.length());
109         mLines.clear();
110         mCurrentLine.clear();
111         mLastTimestamp = -1;
112     }
113 
114     @Override
onData(String s)115     public void onData(String s) {
116         mLine.append(s);
117     }
118 
119     @Override
onStart(String tag, String[] classes, String annotation)120     public void onStart(String tag, String[] classes, String annotation) { }
121 
122     @Override
onEnd(String tag)123     public void onEnd(String tag) { }
124 
125     @Override
onTimeStamp(long timestampMs)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
onLineEnd()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 
getText()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 {
start()179         TokenizerPhase start();
tokenize()180         void tokenize();
181     }
182 
183     class DataTokenizer implements TokenizerPhase {
184         // includes both WebVTT data && escape state
185         private StringBuilder mData;
186 
start()187         public TokenizerPhase start() {
188             mData = new StringBuilder();
189             return this;
190         }
191 
replaceEscape(String escape, String replacement, int pos)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
tokenize()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("&amp;", "&", pos) ||
209                             replaceEscape("&lt;", "<", pos) ||
210                             replaceEscape("&gt;", ">", pos) ||
211                             replaceEscape("&lrm;", "\u200e", pos) ||
212                             replaceEscape("&rlm;", "\u200f", pos) ||
213                             replaceEscape("&nbsp;", "\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 
start()234         public TokenizerPhase start() {
235             mName = mAnnotation = "";
236             mAtAnnotation = false;
237             return this;
238         }
239 
240         @Override
tokenize()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 
yield_tag()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 
Tokenizer(OnTokenListener listener)306     Tokenizer(OnTokenListener listener) {
307         mDataTokenizer = new DataTokenizer();
308         mTagTokenizer = new TagTokenizer();
309         reset();
310         mListener = listener;
311     }
312 
reset()313     void reset() {
314         mPhase = mDataTokenizer.start();
315     }
316 
tokenize(String s)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 {
onData(String s)331         void onData(String s);
onStart(String tag, String[] classes, String annotation)332         void onStart(String tag, String[] classes, String annotation);
onEnd(String tag)333         void onEnd(String tag);
onTimeStamp(long timestampMs)334         void onTimeStamp(long timestampMs);
onLineEnd()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 
TextTrackRegion()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 
toString()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 
TextTrackCue()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
equals(Object o)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 
appendStringsToBuilder(StringBuilder builder)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 
appendLinesToBuilder(StringBuilder builder)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 
toString()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
hashCode()547     public int hashCode() {
548         return toString().hashCode();
549     }
550 
551     @Override
onTime(long timeMs)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 */
562 class 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 
WebVttParser(WebVttCueListener listener)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 */
parseFloatPercentage(String s)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 
parseIntPercentage(String s)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 
parseTimestampMs(String s)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 
timeToString(long timeMs)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 
parse(String s)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 
eos()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 
yieldCue()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 {
parse(String line)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 
log_warning( String nameType, String name, String message, String subMessage, String value)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 
log_warning( String nameType, String name, String message, String value)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 
log_warning(String message, String value)965     private void log_warning(String message, String value) {
966         Log.w(this.getClass().getName(), message + " ('" + value + "')");
967     }
968 }
969 
970 /** @hide */
971 interface WebVttCueListener {
onCueParsed(TextTrackCue cue)972     void onCueParsed(TextTrackCue cue);
onRegionParsed(TextTrackRegion region)973     void onRegionParsed(TextTrackRegion region);
974 }
975 
976 /** @hide */
977 class 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 
WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format)991     WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format) {
992         super(format);
993 
994         mRenderingWidget = renderingWidget;
995     }
996 
997     @Override
getRenderingWidget()998     public WebVttRenderingWidget getRenderingWidget() {
999         return mRenderingWidget;
1000     }
1001 
1002     @Override
onData(String data, boolean eos, long runID)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
onCueParsed(TextTrackCue cue)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
onRegionParsed(TextTrackRegion region)1071     public void onRegionParsed(TextTrackRegion region) {
1072         synchronized(mParser) {
1073             mRegions.put(region.mId, region);
1074         }
1075     }
1076 
1077     @Override
updateView(Vector<SubtitleTrack.Cue> activeCues)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  */
1103 class 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 
WebVttRenderingWidget(Context context)1134     public WebVttRenderingWidget(Context context) {
1135         this(context, null);
1136     }
1137 
WebVttRenderingWidget(Context context, AttributeSet attrs)1138     public WebVttRenderingWidget(Context context, AttributeSet attrs) {
1139         this(context, null, 0);
1140     }
1141 
WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyle)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
setSize(int width, int height)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
onAttachedToWindow()1163     public void onAttachedToWindow() {
1164         super.onAttachedToWindow();
1165 
1166         manageChangeListener();
1167     }
1168 
1169     @Override
onDetachedFromWindow()1170     public void onDetachedFromWindow() {
1171         super.onDetachedFromWindow();
1172 
1173         manageChangeListener();
1174     }
1175 
1176     @Override
setOnChangedListener(OnChangedListener listener)1177     public void setOnChangedListener(OnChangedListener listener) {
1178         mListener = listener;
1179     }
1180 
1181     @Override
setVisible(boolean visible)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      */
manageChangeListener()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 
setActiveCues(Vector<SubtitleTrack.Cue> activeCues)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 
setCaptionStyle(CaptionStyle captionStyle, float fontSize)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      */
prune()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      */
prepForPrune()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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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
onLayout(boolean changed, int l, int t, int r, int b)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      */
layoutRegion( int viewportWidth, int viewportHeight, RegionLayout regionBox)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      */
layoutCue( int viewportWidth, int viewportHeight, CueLayout cueBox)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      */
calculateLinePosition(CueLayout cueBox)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      */
resolveCueAlignment(int layoutDirection, int alignment)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 
RegionLayout(Context context, TextTrackRegion region, CaptionStyle captionStyle, float fontSize)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 
setCaptionStyle(CaptionStyle captionStyle, float fontSize)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          */
measureForParent(int widthMeasureSpec, int heightMeasureSpec)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          */
prepForPrune()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          */
put(TextTrackCue cue)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          */
prune()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          */
getRegion()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 
CueLayout( Context context, TextTrackCue cue, CaptionStyle captionStyle, float fontSize)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 
setCaptionStyle(CaptionStyle style, float fontSize)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 
prepForPrune()1692         public void prepForPrune() {
1693             mActive = false;
1694         }
1695 
update()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
onMeasure(int widthMeasureSpec, int heightMeasureSpec)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          */
measureForParent(int widthMeasureSpec, int heightMeasureSpec)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          */
setOrder(int order)1778         public void setOrder(int order) {
1779             mOrder = order;
1780         }
1781 
1782         /**
1783          * @return whether this cue is marked as active
1784          */
isActive()1785         public boolean isActive() {
1786             return mActive;
1787         }
1788 
1789         /**
1790          * @return the cue data backing this layout
1791          */
getCue()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 
SpanLayout(Context context, TextTrackCueSpan[] spans)1807         public SpanLayout(Context context, TextTrackCueSpan[] spans) {
1808             super(context);
1809 
1810             mSpans = spans;
1811 
1812             update();
1813         }
1814 
update()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 
setCaptionStyle(CaptionStyle captionStyle, float fontSize)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