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