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