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