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