WebVttRenderer.java revision d6479ec5eec13914f656f6be996d95fe1610fd57
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.SpannableStringBuilder;
21import android.util.ArrayMap;
22import android.util.AttributeSet;
23import android.util.Log;
24import android.view.Gravity;
25import android.view.View;
26import android.view.ViewGroup;
27import android.view.accessibility.CaptioningManager;
28import android.view.accessibility.CaptioningManager.CaptionStyle;
29import android.view.accessibility.CaptioningManager.CaptioningChangeListener;
30import android.widget.LinearLayout;
31
32import com.android.internal.widget.SubtitleView;
33
34import java.util.ArrayList;
35import java.util.Arrays;
36import java.util.HashMap;
37import java.util.Map;
38import java.util.Vector;
39
40/** @hide */
41public class WebVttRenderer extends SubtitleController.Renderer {
42    private final Context mContext;
43
44    private WebVttRenderingWidget mRenderingWidget;
45
46    public WebVttRenderer(Context context) {
47        mContext = context;
48    }
49
50    @Override
51    public boolean supports(MediaFormat format) {
52        if (format.containsKey(MediaFormat.KEY_MIME)) {
53            return format.getString(MediaFormat.KEY_MIME).equals("text/vtt");
54        }
55        return false;
56    }
57
58    @Override
59    public SubtitleTrack createTrack(MediaFormat format) {
60        if (mRenderingWidget == null) {
61            mRenderingWidget = new WebVttRenderingWidget(mContext);
62        }
63
64        return new WebVttTrack(mRenderingWidget, format);
65    }
66}
67
68/** @hide */
69class TextTrackCueSpan {
70    long mTimestampMs;
71    boolean mEnabled;
72    String mText;
73    TextTrackCueSpan(String text, long timestamp) {
74        mTimestampMs = timestamp;
75        mText = text;
76        // spans with timestamp will be enabled by Cue.onTime
77        mEnabled = (mTimestampMs < 0);
78    }
79
80    @Override
81    public boolean equals(Object o) {
82        if (!(o instanceof TextTrackCueSpan)) {
83            return false;
84        }
85        TextTrackCueSpan span = (TextTrackCueSpan) o;
86        return mTimestampMs == span.mTimestampMs &&
87                mText.equals(span.mText);
88    }
89}
90
91/**
92 * @hide
93 *
94 * Extract all text without style, but with timestamp spans.
95 */
96class UnstyledTextExtractor implements Tokenizer.OnTokenListener {
97    StringBuilder mLine = new StringBuilder();
98    Vector<TextTrackCueSpan[]> mLines = new Vector<TextTrackCueSpan[]>();
99    Vector<TextTrackCueSpan> mCurrentLine = new Vector<TextTrackCueSpan>();
100    long mLastTimestamp;
101
102    UnstyledTextExtractor() {
103        init();
104    }
105
106    private void init() {
107        mLine.delete(0, mLine.length());
108        mLines.clear();
109        mCurrentLine.clear();
110        mLastTimestamp = -1;
111    }
112
113    @Override
114    public void onData(String s) {
115        mLine.append(s);
116    }
117
118    @Override
119    public void onStart(String tag, String[] classes, String annotation) { }
120
121    @Override
122    public void onEnd(String tag) { }
123
124    @Override
125    public void onTimeStamp(long timestampMs) {
126        // finish any prior span
127        if (mLine.length() > 0 && timestampMs != mLastTimestamp) {
128            mCurrentLine.add(
129                    new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
130            mLine.delete(0, mLine.length());
131        }
132        mLastTimestamp = timestampMs;
133    }
134
135    @Override
136    public void onLineEnd() {
137        // finish any pending span
138        if (mLine.length() > 0) {
139            mCurrentLine.add(
140                    new TextTrackCueSpan(mLine.toString(), mLastTimestamp));
141            mLine.delete(0, mLine.length());
142        }
143
144        TextTrackCueSpan[] spans = new TextTrackCueSpan[mCurrentLine.size()];
145        mCurrentLine.toArray(spans);
146        mCurrentLine.clear();
147        mLines.add(spans);
148    }
149
150    public TextTrackCueSpan[][] getText() {
151        // for politeness, finish last cue-line if it ends abruptly
152        if (mLine.length() > 0 || mCurrentLine.size() > 0) {
153            onLineEnd();
154        }
155        TextTrackCueSpan[][] lines = new TextTrackCueSpan[mLines.size()][];
156        mLines.toArray(lines);
157        init();
158        return lines;
159    }
160}
161
162/**
163 * @hide
164 *
165 * Tokenizer tokenizes the WebVTT Cue Text into tags and data
166 */
167class Tokenizer {
168    private static final String TAG = "Tokenizer";
169    private TokenizerPhase mPhase;
170    private TokenizerPhase mDataTokenizer;
171    private TokenizerPhase mTagTokenizer;
172
173    private OnTokenListener mListener;
174    private String mLine;
175    private int mHandledLen;
176
177    interface TokenizerPhase {
178        TokenizerPhase start();
179        void tokenize();
180    }
181
182    class DataTokenizer implements TokenizerPhase {
183        // includes both WebVTT data && escape state
184        private StringBuilder mData;
185
186        public TokenizerPhase start() {
187            mData = new StringBuilder();
188            return this;
189        }
190
191        private boolean replaceEscape(String escape, String replacement, int pos) {
192            if (mLine.startsWith(escape, pos)) {
193                mData.append(mLine.substring(mHandledLen, pos));
194                mData.append(replacement);
195                mHandledLen = pos + escape.length();
196                pos = mHandledLen - 1;
197                return true;
198            }
199            return false;
200        }
201
202        @Override
203        public void tokenize() {
204            int end = mLine.length();
205            for (int pos = mHandledLen; pos < mLine.length(); pos++) {
206                if (mLine.charAt(pos) == '&') {
207                    if (replaceEscape("&amp;", "&", pos) ||
208                            replaceEscape("&lt;", "<", pos) ||
209                            replaceEscape("&gt;", ">", pos) ||
210                            replaceEscape("&lrm;", "\u200e", pos) ||
211                            replaceEscape("&rlm;", "\u200f", pos) ||
212                            replaceEscape("&nbsp;", "\u00a0", pos)) {
213                        continue;
214                    }
215                } else if (mLine.charAt(pos) == '<') {
216                    end = pos;
217                    mPhase = mTagTokenizer.start();
218                    break;
219                }
220            }
221            mData.append(mLine.substring(mHandledLen, end));
222            // yield mData
223            mListener.onData(mData.toString());
224            mData.delete(0, mData.length());
225            mHandledLen = end;
226        }
227    }
228
229    class TagTokenizer implements TokenizerPhase {
230        private boolean mAtAnnotation;
231        private String mName, mAnnotation;
232
233        public TokenizerPhase start() {
234            mName = mAnnotation = "";
235            mAtAnnotation = false;
236            return this;
237        }
238
239        @Override
240        public void tokenize() {
241            if (!mAtAnnotation)
242                mHandledLen++;
243            if (mHandledLen < mLine.length()) {
244                String[] parts;
245                /**
246                 * Collect annotations and end-tags to closing >.  Collect tag
247                 * name to closing bracket or next white-space.
248                 */
249                if (mAtAnnotation || mLine.charAt(mHandledLen) == '/') {
250                    parts = mLine.substring(mHandledLen).split(">");
251                } else {
252                    parts = mLine.substring(mHandledLen).split("[\t\f >]");
253                }
254                String part = mLine.substring(
255                            mHandledLen, mHandledLen + parts[0].length());
256                mHandledLen += parts[0].length();
257
258                if (mAtAnnotation) {
259                    mAnnotation += " " + part;
260                } else {
261                    mName = part;
262                }
263            }
264
265            mAtAnnotation = true;
266
267            if (mHandledLen < mLine.length() && mLine.charAt(mHandledLen) == '>') {
268                yield_tag();
269                mPhase = mDataTokenizer.start();
270                mHandledLen++;
271            }
272        }
273
274        private void yield_tag() {
275            if (mName.startsWith("/")) {
276                mListener.onEnd(mName.substring(1));
277            } else if (mName.length() > 0 && Character.isDigit(mName.charAt(0))) {
278                // timestamp
279                try {
280                    long timestampMs = WebVttParser.parseTimestampMs(mName);
281                    mListener.onTimeStamp(timestampMs);
282                } catch (NumberFormatException e) {
283                    Log.d(TAG, "invalid timestamp tag: <" + mName + ">");
284                }
285            } else {
286                mAnnotation = mAnnotation.replaceAll("\\s+", " ");
287                if (mAnnotation.startsWith(" ")) {
288                    mAnnotation = mAnnotation.substring(1);
289                }
290                if (mAnnotation.endsWith(" ")) {
291                    mAnnotation = mAnnotation.substring(0, mAnnotation.length() - 1);
292                }
293
294                String[] classes = null;
295                int dotAt = mName.indexOf('.');
296                if (dotAt >= 0) {
297                    classes = mName.substring(dotAt + 1).split("\\.");
298                    mName = mName.substring(0, dotAt);
299                }
300                mListener.onStart(mName, classes, mAnnotation);
301            }
302        }
303    }
304
305    Tokenizer(OnTokenListener listener) {
306        mDataTokenizer = new DataTokenizer();
307        mTagTokenizer = new TagTokenizer();
308        reset();
309        mListener = listener;
310    }
311
312    void reset() {
313        mPhase = mDataTokenizer.start();
314    }
315
316    void tokenize(String s) {
317        mHandledLen = 0;
318        mLine = s;
319        while (mHandledLen < mLine.length()) {
320            mPhase.tokenize();
321        }
322        /* we are finished with a line unless we are in the middle of a tag */
323        if (!(mPhase instanceof TagTokenizer)) {
324            // yield END-OF-LINE
325            mListener.onLineEnd();
326        }
327    }
328
329    interface OnTokenListener {
330        void onData(String s);
331        void onStart(String tag, String[] classes, String annotation);
332        void onEnd(String tag);
333        void onTimeStamp(long timestampMs);
334        void onLineEnd();
335    }
336}
337
338/** @hide */
339class TextTrackRegion {
340    final static int SCROLL_VALUE_NONE      = 300;
341    final static int SCROLL_VALUE_SCROLL_UP = 301;
342
343    String mId;
344    float mWidth;
345    int mLines;
346    float mAnchorPointX, mAnchorPointY;
347    float mViewportAnchorPointX, mViewportAnchorPointY;
348    int mScrollValue;
349
350    TextTrackRegion() {
351        mId = "";
352        mWidth = 100;
353        mLines = 3;
354        mAnchorPointX = mViewportAnchorPointX = 0.f;
355        mAnchorPointY = mViewportAnchorPointY = 100.f;
356        mScrollValue = SCROLL_VALUE_NONE;
357    }
358
359    public String toString() {
360        StringBuilder res = new StringBuilder(" {id:\"").append(mId)
361            .append("\", width:").append(mWidth)
362            .append(", lines:").append(mLines)
363            .append(", anchorPoint:(").append(mAnchorPointX)
364            .append(", ").append(mAnchorPointY)
365            .append("), viewportAnchorPoints:").append(mViewportAnchorPointX)
366            .append(", ").append(mViewportAnchorPointY)
367            .append("), scrollValue:")
368            .append(mScrollValue == SCROLL_VALUE_NONE ? "none" :
369                    mScrollValue == SCROLL_VALUE_SCROLL_UP ? "scroll_up" :
370                    "INVALID")
371            .append("}");
372        return res.toString();
373    }
374}
375
376/** @hide */
377class TextTrackCue extends SubtitleTrack.Cue {
378    final static int WRITING_DIRECTION_HORIZONTAL  = 100;
379    final static int WRITING_DIRECTION_VERTICAL_RL = 101;
380    final static int WRITING_DIRECTION_VERTICAL_LR = 102;
381
382    final static int ALIGNMENT_MIDDLE = 200;
383    final static int ALIGNMENT_START  = 201;
384    final static int ALIGNMENT_END    = 202;
385    final static int ALIGNMENT_LEFT   = 203;
386    final static int ALIGNMENT_RIGHT  = 204;
387    private static final String TAG = "TTCue";
388
389    String  mId;
390    boolean mPauseOnExit;
391    int     mWritingDirection;
392    String  mRegionId;
393    boolean mSnapToLines;
394    Integer mLinePosition;  // null means AUTO
395    boolean mAutoLinePosition;
396    int     mTextPosition;
397    int     mSize;
398    int     mAlignment;
399    // Vector<String> mText;
400    String[] mStrings;
401    TextTrackCueSpan[][] mLines;
402    TextTrackRegion mRegion;
403
404    TextTrackCue() {
405        mId = "";
406        mPauseOnExit = false;
407        mWritingDirection = WRITING_DIRECTION_HORIZONTAL;
408        mRegionId = "";
409        mSnapToLines = true;
410        mLinePosition = null /* AUTO */;
411        mTextPosition = 50;
412        mSize = 100;
413        mAlignment = ALIGNMENT_MIDDLE;
414        mLines = null;
415        mRegion = null;
416    }
417
418    @Override
419    public boolean equals(Object o) {
420        if (!(o instanceof TextTrackCue)) {
421            return false;
422        }
423        if (this == o) {
424            return true;
425        }
426
427        try {
428            TextTrackCue cue = (TextTrackCue) o;
429            boolean res = mId.equals(cue.mId) &&
430                    mPauseOnExit == cue.mPauseOnExit &&
431                    mWritingDirection == cue.mWritingDirection &&
432                    mRegionId.equals(cue.mRegionId) &&
433                    mSnapToLines == cue.mSnapToLines &&
434                    mAutoLinePosition == cue.mAutoLinePosition &&
435                    (mAutoLinePosition || mLinePosition == cue.mLinePosition) &&
436                    mTextPosition == cue.mTextPosition &&
437                    mSize == cue.mSize &&
438                    mAlignment == cue.mAlignment &&
439                    mLines.length == cue.mLines.length;
440            if (res == true) {
441                for (int line = 0; line < mLines.length; line++) {
442                    if (!Arrays.equals(mLines[line], cue.mLines[line])) {
443                        return false;
444                    }
445                }
446            }
447            return res;
448        } catch(IncompatibleClassChangeError e) {
449            return false;
450        }
451    }
452
453    public StringBuilder appendStringsToBuilder(StringBuilder builder) {
454        if (mStrings == null) {
455            builder.append("null");
456        } else {
457            builder.append("[");
458            boolean first = true;
459            for (String s: mStrings) {
460                if (!first) {
461                    builder.append(", ");
462                }
463                if (s == null) {
464                    builder.append("null");
465                } else {
466                    builder.append("\"");
467                    builder.append(s);
468                    builder.append("\"");
469                }
470                first = false;
471            }
472            builder.append("]");
473        }
474        return builder;
475    }
476
477    public StringBuilder appendLinesToBuilder(StringBuilder builder) {
478        if (mLines == null) {
479            builder.append("null");
480        } else {
481            builder.append("[");
482            boolean first = true;
483            for (TextTrackCueSpan[] spans: mLines) {
484                if (!first) {
485                    builder.append(", ");
486                }
487                if (spans == null) {
488                    builder.append("null");
489                } else {
490                    builder.append("\"");
491                    boolean innerFirst = true;
492                    long lastTimestamp = -1;
493                    for (TextTrackCueSpan span: spans) {
494                        if (!innerFirst) {
495                            builder.append(" ");
496                        }
497                        if (span.mTimestampMs != lastTimestamp) {
498                            builder.append("<")
499                                    .append(WebVttParser.timeToString(
500                                            span.mTimestampMs))
501                                    .append(">");
502                            lastTimestamp = span.mTimestampMs;
503                        }
504                        builder.append(span.mText);
505                        innerFirst = false;
506                    }
507                    builder.append("\"");
508                }
509                first = false;
510            }
511            builder.append("]");
512        }
513        return builder;
514    }
515
516    public String toString() {
517        StringBuilder res = new StringBuilder();
518
519        res.append(WebVttParser.timeToString(mStartTimeMs))
520                .append(" --> ").append(WebVttParser.timeToString(mEndTimeMs))
521                .append(" {id:\"").append(mId)
522                .append("\", pauseOnExit:").append(mPauseOnExit)
523                .append(", direction:")
524                .append(mWritingDirection == WRITING_DIRECTION_HORIZONTAL ? "horizontal" :
525                        mWritingDirection == WRITING_DIRECTION_VERTICAL_LR ? "vertical_lr" :
526                        mWritingDirection == WRITING_DIRECTION_VERTICAL_RL ? "vertical_rl" :
527                        "INVALID")
528                .append(", regionId:\"").append(mRegionId)
529                .append("\", snapToLines:").append(mSnapToLines)
530                .append(", linePosition:").append(mAutoLinePosition ? "auto" :
531                                                  mLinePosition)
532                .append(", textPosition:").append(mTextPosition)
533                .append(", size:").append(mSize)
534                .append(", alignment:")
535                .append(mAlignment == ALIGNMENT_END ? "end" :
536                        mAlignment == ALIGNMENT_LEFT ? "left" :
537                        mAlignment == ALIGNMENT_MIDDLE ? "middle" :
538                        mAlignment == ALIGNMENT_RIGHT ? "right" :
539                        mAlignment == ALIGNMENT_START ? "start" : "INVALID")
540                .append(", text:");
541        appendStringsToBuilder(res).append("}");
542        return res.toString();
543    }
544
545    @Override
546    public int hashCode() {
547        return toString().hashCode();
548    }
549
550    @Override
551    public void onTime(long timeMs) {
552        for (TextTrackCueSpan[] line: mLines) {
553            for (TextTrackCueSpan span: line) {
554                span.mEnabled = timeMs >= span.mTimestampMs;
555            }
556        }
557    }
558}
559
560/** @hide */
561class WebVttParser {
562    private static final String TAG = "WebVttParser";
563    private Phase mPhase;
564    private TextTrackCue mCue;
565    private Vector<String> mCueTexts;
566    private WebVttCueListener mListener;
567    private String mBuffer;
568
569    WebVttParser(WebVttCueListener listener) {
570        mPhase = mParseStart;
571        mBuffer = "";   /* mBuffer contains up to 1 incomplete line */
572        mListener = listener;
573        mCueTexts = new Vector<String>();
574    }
575
576    /* parsePercentageString */
577    public static float parseFloatPercentage(String s)
578            throws NumberFormatException {
579        if (!s.endsWith("%")) {
580            throw new NumberFormatException("does not end in %");
581        }
582        s = s.substring(0, s.length() - 1);
583        // parseFloat allows an exponent or a sign
584        if (s.matches(".*[^0-9.].*")) {
585            throw new NumberFormatException("contains an invalid character");
586        }
587
588        try {
589            float value = Float.parseFloat(s);
590            if (value < 0.0f || value > 100.0f) {
591                throw new NumberFormatException("is out of range");
592            }
593            return value;
594        } catch (NumberFormatException e) {
595            throw new NumberFormatException("is not a number");
596        }
597    }
598
599    public static int parseIntPercentage(String s) throws NumberFormatException {
600        if (!s.endsWith("%")) {
601            throw new NumberFormatException("does not end in %");
602        }
603        s = s.substring(0, s.length() - 1);
604        // parseInt allows "-0" that returns 0, so check for non-digits
605        if (s.matches(".*[^0-9].*")) {
606            throw new NumberFormatException("contains an invalid character");
607        }
608
609        try {
610            int value = Integer.parseInt(s);
611            if (value < 0 || value > 100) {
612                throw new NumberFormatException("is out of range");
613            }
614            return value;
615        } catch (NumberFormatException e) {
616            throw new NumberFormatException("is not a number");
617        }
618    }
619
620    public static long parseTimestampMs(String s) throws NumberFormatException {
621        if (!s.matches("(\\d+:)?[0-5]\\d:[0-5]\\d\\.\\d{3}")) {
622            throw new NumberFormatException("has invalid format");
623        }
624
625        String[] parts = s.split("\\.", 2);
626        long value = 0;
627        for (String group: parts[0].split(":")) {
628            value = value * 60 + Long.parseLong(group);
629        }
630        return value * 1000 + Long.parseLong(parts[1]);
631    }
632
633    public static String timeToString(long timeMs) {
634        return String.format("%d:%02d:%02d.%03d",
635                timeMs / 3600000, (timeMs / 60000) % 60,
636                (timeMs / 1000) % 60, timeMs % 1000);
637    }
638
639    public void parse(String s) {
640        boolean trailingCR = false;
641        mBuffer = (mBuffer + s.replace("\0", "\ufffd")).replace("\r\n", "\n");
642
643        /* keep trailing '\r' in case matching '\n' arrives in next packet */
644        if (mBuffer.endsWith("\r")) {
645            trailingCR = true;
646            mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
647        }
648
649        String[] lines = mBuffer.split("[\r\n]");
650        for (int i = 0; i < lines.length - 1; i++) {
651            mPhase.parse(lines[i]);
652        }
653
654        mBuffer = lines[lines.length - 1];
655        if (trailingCR)
656            mBuffer += "\r";
657    }
658
659    public void eos() {
660        if (mBuffer.endsWith("\r")) {
661            mBuffer = mBuffer.substring(0, mBuffer.length() - 1);
662        }
663
664        mPhase.parse(mBuffer);
665        mBuffer = "";
666
667        yieldCue();
668        mPhase = mParseStart;
669    }
670
671    public void yieldCue() {
672        if (mCue != null && mCueTexts.size() > 0) {
673            mCue.mStrings = new String[mCueTexts.size()];
674            mCueTexts.toArray(mCue.mStrings);
675            mCueTexts.clear();
676            mListener.onCueParsed(mCue);
677        }
678        mCue = null;
679    }
680
681    interface Phase {
682        void parse(String line);
683    }
684
685    final private Phase mSkipRest = new Phase() {
686        @Override
687        public void parse(String line) { }
688    };
689
690    final private Phase mParseStart = new Phase() { // 5-9
691        @Override
692        public void parse(String line) {
693            if (!line.equals("WEBVTT") &&
694                    !line.startsWith("WEBVTT ") &&
695                    !line.startsWith("WEBVTT\t")) {
696                log_warning("Not a WEBVTT header", line);
697                mPhase = mSkipRest;
698            } else {
699                mPhase = mParseHeader;
700            }
701        }
702    };
703
704    final private Phase mParseHeader = new Phase() { // 10-13
705        TextTrackRegion parseRegion(String s) {
706            TextTrackRegion region = new TextTrackRegion();
707            for (String setting: s.split(" +")) {
708                int equalAt = setting.indexOf('=');
709                if (equalAt <= 0 || equalAt == setting.length() - 1) {
710                    continue;
711                }
712
713                String name = setting.substring(0, equalAt);
714                String value = setting.substring(equalAt + 1);
715                if (name.equals("id")) {
716                    region.mId = value;
717                } else if (name.equals("width")) {
718                    try {
719                        region.mWidth = parseFloatPercentage(value);
720                    } catch (NumberFormatException e) {
721                        log_warning("region setting", name,
722                                "has invalid value", e.getMessage(), value);
723                    }
724                } else if (name.equals("lines")) {
725                    try {
726                        int lines = Integer.parseInt(value);
727                        if (lines >= 0) {
728                            region.mLines = lines;
729                        } else {
730                            log_warning("region setting", name, "is negative", value);
731                        }
732                    } catch (NumberFormatException e) {
733                        log_warning("region setting", name, "is not numeric", value);
734                    }
735                } else if (name.equals("regionanchor") ||
736                           name.equals("viewportanchor")) {
737                    int commaAt = value.indexOf(",");
738                    if (commaAt < 0) {
739                        log_warning("region setting", name, "contains no comma", value);
740                        continue;
741                    }
742
743                    String anchorX = value.substring(0, commaAt);
744                    String anchorY = value.substring(commaAt + 1);
745                    float x, y;
746
747                    try {
748                        x = parseFloatPercentage(anchorX);
749                    } catch (NumberFormatException e) {
750                        log_warning("region setting", name,
751                                "has invalid x component", e.getMessage(), anchorX);
752                        continue;
753                    }
754                    try {
755                        y = parseFloatPercentage(anchorY);
756                    } catch (NumberFormatException e) {
757                        log_warning("region setting", name,
758                                "has invalid y component", e.getMessage(), anchorY);
759                        continue;
760                    }
761
762                    if (name.charAt(0) == 'r') {
763                        region.mAnchorPointX = x;
764                        region.mAnchorPointY = y;
765                    } else {
766                        region.mViewportAnchorPointX = x;
767                        region.mViewportAnchorPointY = y;
768                    }
769                } else if (name.equals("scroll")) {
770                    if (value.equals("up")) {
771                        region.mScrollValue =
772                            TextTrackRegion.SCROLL_VALUE_SCROLL_UP;
773                    } else {
774                        log_warning("region setting", name, "has invalid value", value);
775                    }
776                }
777            }
778            return region;
779        }
780
781        @Override
782        public void parse(String line)  {
783            if (line.length() == 0) {
784                mPhase = mParseCueId;
785            } else if (line.contains("-->")) {
786                mPhase = mParseCueTime;
787                mPhase.parse(line);
788            } else {
789                int colonAt = line.indexOf(':');
790                if (colonAt <= 0 || colonAt >= line.length() - 1) {
791                    log_warning("meta data header has invalid format", line);
792                }
793                String name = line.substring(0, colonAt);
794                String value = line.substring(colonAt + 1);
795
796                if (name.equals("Region")) {
797                    TextTrackRegion region = parseRegion(value);
798                    mListener.onRegionParsed(region);
799                }
800            }
801        }
802    };
803
804    final private Phase mParseCueId = new Phase() {
805        @Override
806        public void parse(String line) {
807            if (line.length() == 0) {
808                return;
809            }
810
811            assert(mCue == null);
812
813            if (line.equals("NOTE") || line.startsWith("NOTE ")) {
814                mPhase = mParseCueText;
815            }
816
817            mCue = new TextTrackCue();
818            mCueTexts.clear();
819
820            mPhase = mParseCueTime;
821            if (line.contains("-->")) {
822                mPhase.parse(line);
823            } else {
824                mCue.mId = line;
825            }
826        }
827    };
828
829    final private Phase mParseCueTime = new Phase() {
830        @Override
831        public void parse(String line) {
832            int arrowAt = line.indexOf("-->");
833            if (arrowAt < 0) {
834                mCue = null;
835                mPhase = mParseCueId;
836                return;
837            }
838
839            String start = line.substring(0, arrowAt).trim();
840            // convert only initial and first other white-space to space
841            String rest = line.substring(arrowAt + 3)
842                    .replaceFirst("^\\s+", "").replaceFirst("\\s+", " ");
843            int spaceAt = rest.indexOf(' ');
844            String end = spaceAt > 0 ? rest.substring(0, spaceAt) : rest;
845            rest = spaceAt > 0 ? rest.substring(spaceAt + 1) : "";
846
847            mCue.mStartTimeMs = parseTimestampMs(start);
848            mCue.mEndTimeMs = parseTimestampMs(end);
849            for (String setting: rest.split(" +")) {
850                int colonAt = setting.indexOf(':');
851                if (colonAt <= 0 || colonAt == setting.length() - 1) {
852                    continue;
853                }
854                String name = setting.substring(0, colonAt);
855                String value = setting.substring(colonAt + 1);
856
857                if (name.equals("region")) {
858                    mCue.mRegionId = value;
859                } else if (name.equals("vertical")) {
860                    if (value.equals("rl")) {
861                        mCue.mWritingDirection =
862                            TextTrackCue.WRITING_DIRECTION_VERTICAL_RL;
863                    } else if (value.equals("lr")) {
864                        mCue.mWritingDirection =
865                            TextTrackCue.WRITING_DIRECTION_VERTICAL_LR;
866                    } else {
867                        log_warning("cue setting", name, "has invalid value", value);
868                    }
869                } else if (name.equals("line")) {
870                    try {
871                        int linePosition;
872                        /* TRICKY: we know that there are no spaces in value */
873                        assert(value.indexOf(' ') < 0);
874                        if (value.endsWith("%")) {
875                            linePosition = Integer.parseInt(
876                                    value.substring(0, value.length() - 1));
877                            if (linePosition < 0 || linePosition > 100) {
878                                log_warning("cue setting", name, "is out of range", value);
879                                continue;
880                            }
881                            mCue.mSnapToLines = false;
882                            mCue.mLinePosition = linePosition;
883                        } else {
884                            mCue.mSnapToLines = true;
885                            mCue.mLinePosition = Integer.parseInt(value);
886                        }
887                    } catch (NumberFormatException e) {
888                        log_warning("cue setting", name,
889                               "is not numeric or percentage", value);
890                    }
891                } else if (name.equals("position")) {
892                    try {
893                        mCue.mTextPosition = parseIntPercentage(value);
894                    } catch (NumberFormatException e) {
895                        log_warning("cue setting", name,
896                               "is not numeric or percentage", value);
897                    }
898                } else if (name.equals("size")) {
899                    try {
900                        mCue.mSize = parseIntPercentage(value);
901                    } catch (NumberFormatException e) {
902                        log_warning("cue setting", name,
903                               "is not numeric or percentage", value);
904                    }
905                } else if (name.equals("align")) {
906                    if (value.equals("start")) {
907                        mCue.mAlignment = TextTrackCue.ALIGNMENT_START;
908                    } else if (value.equals("middle")) {
909                        mCue.mAlignment = TextTrackCue.ALIGNMENT_MIDDLE;
910                    } else if (value.equals("end")) {
911                        mCue.mAlignment = TextTrackCue.ALIGNMENT_END;
912                    } else if (value.equals("left")) {
913                        mCue.mAlignment = TextTrackCue.ALIGNMENT_LEFT;
914                    } else if (value.equals("right")) {
915                        mCue.mAlignment = TextTrackCue.ALIGNMENT_RIGHT;
916                    } else {
917                        log_warning("cue setting", name, "has invalid value", value);
918                        continue;
919                    }
920                }
921            }
922
923            if (mCue.mLinePosition != null ||
924                    mCue.mSize != 100 ||
925                    (mCue.mWritingDirection !=
926                        TextTrackCue.WRITING_DIRECTION_HORIZONTAL)) {
927                mCue.mRegionId = "";
928            }
929
930            mPhase = mParseCueText;
931        }
932    };
933
934    /* also used for notes */
935    final private Phase mParseCueText = new Phase() {
936        @Override
937        public void parse(String line) {
938            if (line.length() == 0) {
939                yieldCue();
940                mPhase = mParseCueId;
941                return;
942            } else if (mCue != null) {
943                mCueTexts.add(line);
944            }
945        }
946    };
947
948    private void log_warning(
949            String nameType, String name, String message,
950            String subMessage, String value) {
951        Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
952                message + " ('" + value + "' " + subMessage + ")");
953    }
954
955    private void log_warning(
956            String nameType, String name, String message, String value) {
957        Log.w(this.getClass().getName(), nameType + " '" + name + "' " +
958                message + " ('" + value + "')");
959    }
960
961    private void log_warning(String message, String value) {
962        Log.w(this.getClass().getName(), message + " ('" + value + "')");
963    }
964}
965
966/** @hide */
967interface WebVttCueListener {
968    void onCueParsed(TextTrackCue cue);
969    void onRegionParsed(TextTrackRegion region);
970}
971
972/** @hide */
973class WebVttTrack extends SubtitleTrack implements WebVttCueListener {
974    private static final String TAG = "WebVttTrack";
975
976    private final WebVttParser mParser = new WebVttParser(this);
977    private final UnstyledTextExtractor mExtractor =
978        new UnstyledTextExtractor();
979    private final Tokenizer mTokenizer = new Tokenizer(mExtractor);
980    private final Vector<Long> mTimestamps = new Vector<Long>();
981    private final WebVttRenderingWidget mRenderingWidget;
982
983    private final Map<String, TextTrackRegion> mRegions =
984        new HashMap<String, TextTrackRegion>();
985    private Long mCurrentRunID;
986
987    WebVttTrack(WebVttRenderingWidget renderingWidget, MediaFormat format) {
988        super(format);
989
990        mRenderingWidget = renderingWidget;
991    }
992
993    @Override
994    public WebVttRenderingWidget getRenderingWidget() {
995        return mRenderingWidget;
996    }
997
998    @Override
999    public void onData(String data, boolean eos, long runID) {
1000        // implement intermixing restriction for WebVTT only for now
1001        synchronized(mParser) {
1002            if (mCurrentRunID != null && runID != mCurrentRunID) {
1003                throw new IllegalStateException(
1004                        "Run #" + mCurrentRunID +
1005                        " in progress.  Cannot process run #" + runID);
1006            }
1007            mCurrentRunID = runID;
1008            mParser.parse(data);
1009            if (eos) {
1010                finishedRun(runID);
1011                mParser.eos();
1012                mRegions.clear();
1013                mCurrentRunID = null;
1014            }
1015        }
1016    }
1017
1018    @Override
1019    public void onCueParsed(TextTrackCue cue) {
1020        synchronized (mParser) {
1021            // resolve region
1022            if (cue.mRegionId.length() != 0) {
1023                cue.mRegion = mRegions.get(cue.mRegionId);
1024            }
1025
1026            if (DEBUG) Log.v(TAG, "adding cue " + cue);
1027
1028            // tokenize text track string-lines into lines of spans
1029            mTokenizer.reset();
1030            for (String s: cue.mStrings) {
1031                mTokenizer.tokenize(s);
1032            }
1033            cue.mLines = mExtractor.getText();
1034            if (DEBUG) Log.v(TAG, cue.appendLinesToBuilder(
1035                    cue.appendStringsToBuilder(
1036                        new StringBuilder()).append(" simplified to: "))
1037                            .toString());
1038
1039            // extract inner timestamps
1040            for (TextTrackCueSpan[] line: cue.mLines) {
1041                for (TextTrackCueSpan span: line) {
1042                    if (span.mTimestampMs > cue.mStartTimeMs &&
1043                            span.mTimestampMs < cue.mEndTimeMs &&
1044                            !mTimestamps.contains(span.mTimestampMs)) {
1045                        mTimestamps.add(span.mTimestampMs);
1046                    }
1047                }
1048            }
1049
1050            if (mTimestamps.size() > 0) {
1051                cue.mInnerTimesMs = new long[mTimestamps.size()];
1052                for (int ix=0; ix < mTimestamps.size(); ++ix) {
1053                    cue.mInnerTimesMs[ix] = mTimestamps.get(ix);
1054                }
1055                mTimestamps.clear();
1056            } else {
1057                cue.mInnerTimesMs = null;
1058            }
1059
1060            cue.mRunID = mCurrentRunID;
1061        }
1062
1063        addCue(cue);
1064    }
1065
1066    @Override
1067    public void onRegionParsed(TextTrackRegion region) {
1068        synchronized(mParser) {
1069            mRegions.put(region.mId, region);
1070        }
1071    }
1072
1073    @Override
1074    public void updateView(Vector<SubtitleTrack.Cue> activeCues) {
1075        if (!mVisible) {
1076            // don't keep the state if we are not visible
1077            return;
1078        }
1079
1080        if (DEBUG && mTimeProvider != null) {
1081            try {
1082                Log.d(TAG, "at " +
1083                        (mTimeProvider.getCurrentTimeUs(false, true) / 1000) +
1084                        " ms the active cues are:");
1085            } catch (IllegalStateException e) {
1086                Log.d(TAG, "at (illegal state) the active cues are:");
1087            }
1088        }
1089
1090        mRenderingWidget.setActiveCues(activeCues);
1091    }
1092}
1093
1094/**
1095 * Widget capable of rendering WebVTT captions.
1096 *
1097 * @hide
1098 */
1099class WebVttRenderingWidget extends ViewGroup implements SubtitleTrack.RenderingWidget {
1100    private static final boolean DEBUG = false;
1101    private static final int DEBUG_REGION_BACKGROUND = 0x800000FF;
1102    private static final int DEBUG_CUE_BACKGROUND = 0x80FF0000;
1103
1104    /** WebVtt specifies line height as 5.3% of the viewport height. */
1105    private static final float LINE_HEIGHT_RATIO = 0.0533f;
1106
1107    /** Map of active regions, used to determine enter/exit. */
1108    private final ArrayMap<TextTrackRegion, RegionLayout> mRegionBoxes =
1109            new ArrayMap<TextTrackRegion, RegionLayout>();
1110
1111    /** Map of active cues, used to determine enter/exit. */
1112    private final ArrayMap<TextTrackCue, CueLayout> mCueBoxes =
1113            new ArrayMap<TextTrackCue, CueLayout>();
1114
1115    /** Captioning manager, used to obtain and track caption properties. */
1116    private final CaptioningManager mManager;
1117
1118    /** Callback for rendering changes. */
1119    private OnChangedListener mListener;
1120
1121    /** Current caption style. */
1122    private CaptionStyle mCaptionStyle;
1123
1124    /** Current font size, computed from font scaling factor and height. */
1125    private float mFontSize;
1126
1127    /** Whether a caption style change listener is registered. */
1128    private boolean mHasChangeListener;
1129
1130    public WebVttRenderingWidget(Context context) {
1131        this(context, null);
1132    }
1133
1134    public WebVttRenderingWidget(Context context, AttributeSet attrs) {
1135        this(context, attrs, 0);
1136    }
1137
1138    public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr) {
1139        this(context, attrs, defStyleAttr, 0);
1140    }
1141
1142    public WebVttRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
1143        super(context, attrs, defStyleAttr, defStyleRes);
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            addView(cueBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
1588
1589            if (getChildCount() > mRegion.mLines) {
1590                removeViewAt(0);
1591            }
1592        }
1593
1594        /**
1595         * Remove all inactive tracks from this region.
1596         *
1597         * @return true if this region is empty and should be pruned
1598         */
1599        public boolean prune() {
1600            int cueCount = mRegionCueBoxes.size();
1601            for (int i = 0; i < cueCount; i++) {
1602                final CueLayout cueBox = mRegionCueBoxes.get(i);
1603                if (!cueBox.isActive()) {
1604                    mRegionCueBoxes.remove(i);
1605                    removeView(cueBox);
1606                    cueCount--;
1607                    i--;
1608                }
1609            }
1610
1611            return mRegionCueBoxes.isEmpty();
1612        }
1613
1614        /**
1615         * @return the region data backing this layout
1616         */
1617        public TextTrackRegion getRegion() {
1618            return mRegion;
1619        }
1620    }
1621
1622    /**
1623     * A text track cue is the unit of time-sensitive data in a text track,
1624     * corresponding for instance for subtitles and captions to the text that
1625     * appears at a particular time and disappears at another time.
1626     * <p>
1627     * A single cue may contain multiple {@link SpanLayout}s, each representing a
1628     * single line of text.
1629     */
1630    private static class CueLayout extends LinearLayout {
1631        public final TextTrackCue mCue;
1632
1633        private CaptionStyle mCaptionStyle;
1634        private float mFontSize;
1635
1636        private boolean mActive;
1637        private int mOrder;
1638
1639        public CueLayout(
1640                Context context, TextTrackCue cue, CaptionStyle captionStyle, float fontSize) {
1641            super(context);
1642
1643            mCue = cue;
1644            mCaptionStyle = captionStyle;
1645            mFontSize = fontSize;
1646
1647            // TODO: Add support for vertical text.
1648            final boolean horizontal = cue.mWritingDirection
1649                    == TextTrackCue.WRITING_DIRECTION_HORIZONTAL;
1650            setOrientation(horizontal ? VERTICAL : HORIZONTAL);
1651
1652            switch (cue.mAlignment) {
1653                case TextTrackCue.ALIGNMENT_END:
1654                    setGravity(Gravity.END);
1655                    break;
1656                case TextTrackCue.ALIGNMENT_LEFT:
1657                    setGravity(Gravity.LEFT);
1658                    break;
1659                case TextTrackCue.ALIGNMENT_MIDDLE:
1660                    setGravity(horizontal
1661                            ? Gravity.CENTER_HORIZONTAL : Gravity.CENTER_VERTICAL);
1662                    break;
1663                case TextTrackCue.ALIGNMENT_RIGHT:
1664                    setGravity(Gravity.RIGHT);
1665                    break;
1666                case TextTrackCue.ALIGNMENT_START:
1667                    setGravity(Gravity.START);
1668                    break;
1669            }
1670
1671            if (DEBUG) {
1672                setBackgroundColor(DEBUG_CUE_BACKGROUND);
1673            }
1674
1675            update();
1676        }
1677
1678        public void setCaptionStyle(CaptionStyle style, float fontSize) {
1679            mCaptionStyle = style;
1680            mFontSize = fontSize;
1681
1682            final int n = getChildCount();
1683            for (int i = 0; i < n; i++) {
1684                final View child = getChildAt(i);
1685                if (child instanceof SpanLayout) {
1686                    ((SpanLayout) child).setCaptionStyle(style, fontSize);
1687                }
1688            }
1689        }
1690
1691        public void prepForPrune() {
1692            mActive = false;
1693        }
1694
1695        public void update() {
1696            mActive = true;
1697
1698            removeAllViews();
1699
1700            final CaptionStyle captionStyle = mCaptionStyle;
1701            final float fontSize = mFontSize;
1702            final TextTrackCueSpan[][] lines = mCue.mLines;
1703            final int lineCount = lines.length;
1704            for (int i = 0; i < lineCount; i++) {
1705                final SpanLayout lineBox = new SpanLayout(getContext(), lines[i]);
1706                lineBox.setCaptionStyle(captionStyle, fontSize);
1707
1708                addView(lineBox, LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
1709            }
1710        }
1711
1712        @Override
1713        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
1714            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
1715        }
1716
1717        /**
1718         * Performs the parent's measurement responsibilities, then
1719         * automatically performs its own measurement.
1720         */
1721        public void measureForParent(int widthMeasureSpec, int heightMeasureSpec) {
1722            final TextTrackCue cue = mCue;
1723            final int specWidth = MeasureSpec.getSize(widthMeasureSpec);
1724            final int specHeight = MeasureSpec.getSize(heightMeasureSpec);
1725            final int direction = getLayoutDirection();
1726            final int absAlignment = resolveCueAlignment(direction, cue.mAlignment);
1727
1728            // Determine the maximum size of cue based on its starting position
1729            // and the direction in which it grows.
1730            final int maximumSize;
1731            switch (absAlignment) {
1732                case TextTrackCue.ALIGNMENT_LEFT:
1733                    maximumSize = 100 - cue.mTextPosition;
1734                    break;
1735                case TextTrackCue.ALIGNMENT_RIGHT:
1736                    maximumSize = cue.mTextPosition;
1737                    break;
1738                case TextTrackCue.ALIGNMENT_MIDDLE:
1739                    if (cue.mTextPosition <= 50) {
1740                        maximumSize = cue.mTextPosition * 2;
1741                    } else {
1742                        maximumSize = (100 - cue.mTextPosition) * 2;
1743                    }
1744                    break;
1745                default:
1746                    maximumSize = 0;
1747            }
1748
1749            // Determine absolute maximum cue size as the smaller of the
1750            // requested size and the maximum theoretical size.
1751            final int size = Math.min(cue.mSize, maximumSize) * specWidth / 100;
1752            widthMeasureSpec = MeasureSpec.makeMeasureSpec(size, MeasureSpec.AT_MOST);
1753            heightMeasureSpec = MeasureSpec.makeMeasureSpec(specHeight, MeasureSpec.AT_MOST);
1754            measure(widthMeasureSpec, heightMeasureSpec);
1755        }
1756
1757        /**
1758         * Sets the order of this cue in the list of active cues.
1759         *
1760         * @param order the order of this cue in the list of active cues
1761         */
1762        public void setOrder(int order) {
1763            mOrder = order;
1764        }
1765
1766        /**
1767         * @return whether this cue is marked as active
1768         */
1769        public boolean isActive() {
1770            return mActive;
1771        }
1772
1773        /**
1774         * @return the cue data backing this layout
1775         */
1776        public TextTrackCue getCue() {
1777            return mCue;
1778        }
1779    }
1780
1781    /**
1782     * A text track line represents a single line of text within a cue.
1783     * <p>
1784     * A single line may contain multiple spans, each representing a section of
1785     * text that may be enabled or disabled at a particular time.
1786     */
1787    private static class SpanLayout extends SubtitleView {
1788        private final SpannableStringBuilder mBuilder = new SpannableStringBuilder();
1789        private final TextTrackCueSpan[] mSpans;
1790
1791        public SpanLayout(Context context, TextTrackCueSpan[] spans) {
1792            super(context);
1793
1794            mSpans = spans;
1795
1796            update();
1797        }
1798
1799        public void update() {
1800            final SpannableStringBuilder builder = mBuilder;
1801            final TextTrackCueSpan[] spans = mSpans;
1802
1803            builder.clear();
1804            builder.clearSpans();
1805
1806            final int spanCount = spans.length;
1807            for (int i = 0; i < spanCount; i++) {
1808                final TextTrackCueSpan span = spans[i];
1809                if (span.mEnabled) {
1810                    builder.append(spans[i].mText);
1811                }
1812            }
1813
1814            setText(builder);
1815        }
1816
1817        public void setCaptionStyle(CaptionStyle captionStyle, float fontSize) {
1818            setBackgroundColor(captionStyle.backgroundColor);
1819            setForegroundColor(captionStyle.foregroundColor);
1820            setEdgeColor(captionStyle.edgeColor);
1821            setEdgeType(captionStyle.edgeType);
1822            setTypeface(captionStyle.getTypeface());
1823            setTextSize(fontSize);
1824        }
1825    }
1826}
1827