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