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