summaryrefslogtreecommitdiffstats
path: root/media/java/android/media/TtmlRenderer.java
blob: 9d587b94b1c887e2028e8a867da78457766bf961 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.media;

import android.content.Context;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.accessibility.CaptioningManager;
import android.widget.LinearLayout;
import android.widget.TextView;

import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.TreeSet;
import java.util.Vector;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;

/** @hide */
public class TtmlRenderer extends SubtitleController.Renderer {
    private final Context mContext;

    private static final String MEDIA_MIMETYPE_TEXT_TTML = "application/ttml+xml";

    private TtmlRenderingWidget mRenderingWidget;

    public TtmlRenderer(Context context) {
        mContext = context;
    }

    @Override
    public boolean supports(MediaFormat format) {
        if (format.containsKey(MediaFormat.KEY_MIME)) {
            return format.getString(MediaFormat.KEY_MIME).equals(MEDIA_MIMETYPE_TEXT_TTML);
        }
        return false;
    }

    @Override
    public SubtitleTrack createTrack(MediaFormat format) {
        if (mRenderingWidget == null) {
            mRenderingWidget = new TtmlRenderingWidget(mContext);
        }
        return new TtmlTrack(mRenderingWidget, format);
    }
}

/**
 * A class which provides utillity methods for TTML parsing.
 *
 * @hide
 */
final class TtmlUtils {
    public static final String TAG_TT = "tt";
    public static final String TAG_HEAD = "head";
    public static final String TAG_BODY = "body";
    public static final String TAG_DIV = "div";
    public static final String TAG_P = "p";
    public static final String TAG_SPAN = "span";
    public static final String TAG_BR = "br";
    public static final String TAG_STYLE = "style";
    public static final String TAG_STYLING = "styling";
    public static final String TAG_LAYOUT = "layout";
    public static final String TAG_REGION = "region";
    public static final String TAG_METADATA = "metadata";
    public static final String TAG_SMPTE_IMAGE = "smpte:image";
    public static final String TAG_SMPTE_DATA = "smpte:data";
    public static final String TAG_SMPTE_INFORMATION = "smpte:information";
    public static final String PCDATA = "#pcdata";
    public static final String ATTR_BEGIN = "begin";
    public static final String ATTR_DURATION = "dur";
    public static final String ATTR_END = "end";
    public static final long INVALID_TIMESTAMP = Long.MAX_VALUE;

    /**
     * Time expression RE according to the spec:
     * http://www.w3.org/TR/ttaf1-dfxp/#timing-value-timeExpression
     */
    private static final Pattern CLOCK_TIME = Pattern.compile(
            "^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])"
            + "(?:(\\.[0-9]+)|:([0-9][0-9])(?:\\.([0-9]+))?)?$");

    private static final Pattern OFFSET_TIME = Pattern.compile(
            "^([0-9]+(?:\\.[0-9]+)?)(h|m|s|ms|f|t)$");

    private TtmlUtils() {
    }

    /**
     * Parses the given time expression and returns a timestamp in millisecond.
     * <p>
     * For the format of the time expression, please refer <a href=
     * "http://www.w3.org/TR/ttaf1-dfxp/#timing-value-timeExpression">timeExpression</a>
     *
     * @param time A string which includes time expression.
     * @param frameRate the framerate of the stream.
     * @param subframeRate the sub-framerate of the stream
     * @param tickRate the tick rate of the stream.
     * @return the parsed timestamp in micro-second.
     * @throws NumberFormatException if the given string does not match to the
     *             format.
     */
    public static long parseTimeExpression(String time, int frameRate, int subframeRate,
            int tickRate) throws NumberFormatException {
        Matcher matcher = CLOCK_TIME.matcher(time);
        if (matcher.matches()) {
            String hours = matcher.group(1);
            double durationSeconds = Long.parseLong(hours) * 3600;
            String minutes = matcher.group(2);
            durationSeconds += Long.parseLong(minutes) * 60;
            String seconds = matcher.group(3);
            durationSeconds += Long.parseLong(seconds);
            String fraction = matcher.group(4);
            durationSeconds += (fraction != null) ? Double.parseDouble(fraction) : 0;
            String frames = matcher.group(5);
            durationSeconds += (frames != null) ? ((double)Long.parseLong(frames)) / frameRate : 0;
            String subframes = matcher.group(6);
            durationSeconds += (subframes != null) ? ((double)Long.parseLong(subframes))
                    / subframeRate / frameRate
                    : 0;
            return (long)(durationSeconds * 1000);
        }
        matcher = OFFSET_TIME.matcher(time);
        if (matcher.matches()) {
            String timeValue = matcher.group(1);
            double value = Double.parseDouble(timeValue);
            String unit = matcher.group(2);
            if (unit.equals("h")) {
                value *= 3600L * 1000000L;
            } else if (unit.equals("m")) {
                value *= 60 * 1000000;
            } else if (unit.equals("s")) {
                value *= 1000000;
            } else if (unit.equals("ms")) {
                value *= 1000;
            } else if (unit.equals("f")) {
                value = value / frameRate * 1000000;
            } else if (unit.equals("t")) {
                value = value / tickRate * 1000000;
            }
            return (long)value;
        }
        throw new NumberFormatException("Malformed time expression : " + time);
    }

    /**
     * Applies <a href
     * src="http://www.w3.org/TR/ttaf1-dfxp/#content-attribute-space">the
     * default space policy</a> to the given string.
     *
     * @param in A string to apply the policy.
     */
    public static String applyDefaultSpacePolicy(String in) {
        return applySpacePolicy(in, true);
    }

    /**
     * Applies the space policy to the given string. This applies <a href
     * src="http://www.w3.org/TR/ttaf1-dfxp/#content-attribute-space">the
     * default space policy</a> with linefeed-treatment as treat-as-space
     * or preserve.
     *
     * @param in A string to apply the policy.
     * @param treatLfAsSpace Whether convert line feeds to spaces or not.
     */
    public static String applySpacePolicy(String in, boolean treatLfAsSpace) {
        // Removes CR followed by LF. ref:
        // http://www.w3.org/TR/xml/#sec-line-ends
        String crRemoved = in.replaceAll("\r\n", "\n");
        // Apply suppress-at-line-break="auto" and
        // white-space-treatment="ignore-if-surrounding-linefeed"
        String spacesNeighboringLfRemoved = crRemoved.replaceAll(" *\n *", "\n");
        // Apply linefeed-treatment="treat-as-space"
        String lfToSpace = treatLfAsSpace ? spacesNeighboringLfRemoved.replaceAll("\n", " ")
                : spacesNeighboringLfRemoved;
        // Apply white-space-collapse="true"
        String spacesCollapsed = lfToSpace.replaceAll("[ \t\\x0B\f\r]+", " ");
        return spacesCollapsed;
    }

    /**
     * Returns the timed text for the given time period.
     *
     * @param root The root node of the TTML document.
     * @param startUs The start time of the time period in microsecond.
     * @param endUs The end time of the time period in microsecond.
     */
    public static String extractText(TtmlNode root, long startUs, long endUs) {
        StringBuilder text = new StringBuilder();
        extractText(root, startUs, endUs, text, false);
        return text.toString().replaceAll("\n$", "");
    }

    private static void extractText(TtmlNode node, long startUs, long endUs, StringBuilder out,
            boolean inPTag) {
        if (node.mName.equals(TtmlUtils.PCDATA) && inPTag) {
            out.append(node.mText);
        } else if (node.mName.equals(TtmlUtils.TAG_BR) && inPTag) {
            out.append("\n");
        } else if (node.mName.equals(TtmlUtils.TAG_METADATA)) {
            // do nothing.
        } else if (node.isActive(startUs, endUs)) {
            boolean pTag = node.mName.equals(TtmlUtils.TAG_P);
            int length = out.length();
            for (int i = 0; i < node.mChildren.size(); ++i) {
                extractText(node.mChildren.get(i), startUs, endUs, out, pTag || inPTag);
            }
            if (pTag && length != out.length()) {
                out.append("\n");
            }
        }
    }

    /**
     * Returns a TTML fragment string for the given time period.
     *
     * @param root The root node of the TTML document.
     * @param startUs The start time of the time period in microsecond.
     * @param endUs The end time of the time period in microsecond.
     */
    public static String extractTtmlFragment(TtmlNode root, long startUs, long endUs) {
        StringBuilder fragment = new StringBuilder();
        extractTtmlFragment(root, startUs, endUs, fragment);
        return fragment.toString();
    }

    private static void extractTtmlFragment(TtmlNode node, long startUs, long endUs,
            StringBuilder out) {
        if (node.mName.equals(TtmlUtils.PCDATA)) {
            out.append(node.mText);
        } else if (node.mName.equals(TtmlUtils.TAG_BR)) {
            out.append("<br/>");
        } else if (node.isActive(startUs, endUs)) {
            out.append("<");
            out.append(node.mName);
            out.append(node.mAttributes);
            out.append(">");
            for (int i = 0; i < node.mChildren.size(); ++i) {
                extractTtmlFragment(node.mChildren.get(i), startUs, endUs, out);
            }
            out.append("</");
            out.append(node.mName);
            out.append(">");
        }
    }
}

/**
 * A container class which represents a cue in TTML.
 * @hide
 */
class TtmlCue extends SubtitleTrack.Cue {
    public String mText;
    public String mTtmlFragment;

    public TtmlCue(long startTimeMs, long endTimeMs, String text, String ttmlFragment) {
        this.mStartTimeMs = startTimeMs;
        this.mEndTimeMs = endTimeMs;
        this.mText = text;
        this.mTtmlFragment = ttmlFragment;
    }
}

/**
 * A container class which represents a node in TTML.
 *
 * @hide
 */
class TtmlNode {
    public final String mName;
    public final String mAttributes;
    public final TtmlNode mParent;
    public final String mText;
    public final List<TtmlNode> mChildren = new ArrayList<TtmlNode>();
    public final long mRunId;
    public final long mStartTimeMs;
    public final long mEndTimeMs;

    public TtmlNode(String name, String attributes, String text, long startTimeMs, long endTimeMs,
            TtmlNode parent, long runId) {
        this.mName = name;
        this.mAttributes = attributes;
        this.mText = text;
        this.mStartTimeMs = startTimeMs;
        this.mEndTimeMs = endTimeMs;
        this.mParent = parent;
        this.mRunId = runId;
    }

    /**
     * Check if this node is active in the given time range.
     *
     * @param startTimeMs The start time of the range to check in microsecond.
     * @param endTimeMs The end time of the range to check in microsecond.
     * @return return true if the given range overlaps the time range of this
     *         node.
     */
    public boolean isActive(long startTimeMs, long endTimeMs) {
        return this.mEndTimeMs > startTimeMs && this.mStartTimeMs < endTimeMs;
    }
}

/**
 * A simple TTML parser (http://www.w3.org/TR/ttaf1-dfxp/) which supports DFXP
 * presentation profile.
 * <p>
 * Supported features in this parser are:
 * <ul>
 * <li>content
 * <li>core
 * <li>presentation
 * <li>profile
 * <li>structure
 * <li>time-offset
 * <li>timing
 * <li>tickRate
 * <li>time-clock-with-frames
 * <li>time-clock
 * <li>time-offset-with-frames
 * <li>time-offset-with-ticks
 * </ul>
 * </p>
 *
 * @hide
 */
class TtmlParser {
    static final String TAG = "TtmlParser";

    // TODO: read and apply the following attributes if specified.
    private static final int DEFAULT_FRAMERATE = 30;
    private static final int DEFAULT_SUBFRAMERATE = 1;
    private static final int DEFAULT_TICKRATE = 1;

    private XmlPullParser mParser;
    private final TtmlNodeListener mListener;
    private long mCurrentRunId;

    public TtmlParser(TtmlNodeListener listener) {
        mListener = listener;
    }

    /**
     * Parse TTML data. Once this is called, all the previous data are
     * reset and it starts parsing for the given text.
     *
     * @param ttmlText TTML text to parse.
     * @throws XmlPullParserException
     * @throws IOException
     */
    public void parse(String ttmlText, long runId) throws XmlPullParserException, IOException {
        mParser = null;
        mCurrentRunId = runId;
        loadParser(ttmlText);
        parseTtml();
    }

    private void loadParser(String ttmlFragment) throws XmlPullParserException {
        XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
        factory.setNamespaceAware(false);
        mParser = factory.newPullParser();
        StringReader in = new StringReader(ttmlFragment);
        mParser.setInput(in);
    }

    private void extractAttribute(XmlPullParser parser, int i, StringBuilder out) {
        out.append(" ");
        out.append(parser.getAttributeName(i));
        out.append("=\"");
        out.append(parser.getAttributeValue(i));
        out.append("\"");
    }

    private void parseTtml() throws XmlPullParserException, IOException {
        LinkedList<TtmlNode> nodeStack = new LinkedList<TtmlNode>();
        int depthInUnsupportedTag = 0;
        boolean active = true;
        while (!isEndOfDoc()) {
            int eventType = mParser.getEventType();
            TtmlNode parent = nodeStack.peekLast();
            if (active) {
                if (eventType == XmlPullParser.START_TAG) {
                    if (!isSupportedTag(mParser.getName())) {
                        Log.w(TAG, "Unsupported tag " + mParser.getName() + " is ignored.");
                        depthInUnsupportedTag++;
                        active = false;
                    } else {
                        TtmlNode node = parseNode(parent);
                        nodeStack.addLast(node);
                        if (parent != null) {
                            parent.mChildren.add(node);
                        }
                    }
                } else if (eventType == XmlPullParser.TEXT) {
                    String text = TtmlUtils.applyDefaultSpacePolicy(mParser.getText());
                    if (!TextUtils.isEmpty(text)) {
                        parent.mChildren.add(new TtmlNode(
                                TtmlUtils.PCDATA, "", text, 0, TtmlUtils.INVALID_TIMESTAMP,
                                parent, mCurrentRunId));

                    }
                } else if (eventType == XmlPullParser.END_TAG) {
                    if (mParser.getName().equals(TtmlUtils.TAG_P)) {
                        mListener.onTtmlNodeParsed(nodeStack.getLast());
                    } else if (mParser.getName().equals(TtmlUtils.TAG_TT)) {
                        mListener.onRootNodeParsed(nodeStack.getLast());
                    }
                    nodeStack.removeLast();
                }
            } else {
                if (eventType == XmlPullParser.START_TAG) {
                    depthInUnsupportedTag++;
                } else if (eventType == XmlPullParser.END_TAG) {
                    depthInUnsupportedTag--;
                    if (depthInUnsupportedTag == 0) {
                        active = true;
                    }
                }
            }
            mParser.next();
        }
    }

    private TtmlNode parseNode(TtmlNode parent) throws XmlPullParserException, IOException {
        int eventType = mParser.getEventType();
        if (!(eventType == XmlPullParser.START_TAG)) {
            return null;
        }
        StringBuilder attrStr = new StringBuilder();
        long start = 0;
        long end = TtmlUtils.INVALID_TIMESTAMP;
        long dur = 0;
        for (int i = 0; i < mParser.getAttributeCount(); ++i) {
            String attr = mParser.getAttributeName(i);
            String value = mParser.getAttributeValue(i);
            // TODO: check if it's safe to ignore the namespace of attributes as follows.
            attr = attr.replaceFirst("^.*:", "");
            if (attr.equals(TtmlUtils.ATTR_BEGIN)) {
                start = TtmlUtils.parseTimeExpression(value, DEFAULT_FRAMERATE,
                        DEFAULT_SUBFRAMERATE, DEFAULT_TICKRATE);
            } else if (attr.equals(TtmlUtils.ATTR_END)) {
                end = TtmlUtils.parseTimeExpression(value, DEFAULT_FRAMERATE, DEFAULT_SUBFRAMERATE,
                        DEFAULT_TICKRATE);
            } else if (attr.equals(TtmlUtils.ATTR_DURATION)) {
                dur = TtmlUtils.parseTimeExpression(value, DEFAULT_FRAMERATE, DEFAULT_SUBFRAMERATE,
                        DEFAULT_TICKRATE);
            } else {
                extractAttribute(mParser, i, attrStr);
            }
        }
        if (parent != null) {
            start += parent.mStartTimeMs;
            if (end != TtmlUtils.INVALID_TIMESTAMP) {
                end += parent.mStartTimeMs;
            }
        }
        if (dur > 0) {
            if (end != TtmlUtils.INVALID_TIMESTAMP) {
                Log.e(TAG, "'dur' and 'end' attributes are defined at the same time." +
                        "'end' value is ignored.");
            }
            end = start + dur;
        }
        if (parent != null) {
            // If the end time remains unspecified, then the end point is
            // interpreted as the end point of the external time interval.
            if (end == TtmlUtils.INVALID_TIMESTAMP &&
                    parent.mEndTimeMs != TtmlUtils.INVALID_TIMESTAMP &&
                    end > parent.mEndTimeMs) {
                end = parent.mEndTimeMs;
            }
        }
        TtmlNode node = new TtmlNode(mParser.getName(), attrStr.toString(), null, start, end,
                parent, mCurrentRunId);
        return node;
    }

    private boolean isEndOfDoc() throws XmlPullParserException {
        return (mParser.getEventType() == XmlPullParser.END_DOCUMENT);
    }

    private static boolean isSupportedTag(String tag) {
        if (tag.equals(TtmlUtils.TAG_TT) || tag.equals(TtmlUtils.TAG_HEAD) ||
                tag.equals(TtmlUtils.TAG_BODY) || tag.equals(TtmlUtils.TAG_DIV) ||
                tag.equals(TtmlUtils.TAG_P) || tag.equals(TtmlUtils.TAG_SPAN) ||
                tag.equals(TtmlUtils.TAG_BR) || tag.equals(TtmlUtils.TAG_STYLE) ||
                tag.equals(TtmlUtils.TAG_STYLING) || tag.equals(TtmlUtils.TAG_LAYOUT) ||
                tag.equals(TtmlUtils.TAG_REGION) || tag.equals(TtmlUtils.TAG_METADATA) ||
                tag.equals(TtmlUtils.TAG_SMPTE_IMAGE) || tag.equals(TtmlUtils.TAG_SMPTE_DATA) ||
                tag.equals(TtmlUtils.TAG_SMPTE_INFORMATION)) {
            return true;
        }
        return false;
    }
}

/** @hide */
interface TtmlNodeListener {
    void onTtmlNodeParsed(TtmlNode node);
    void onRootNodeParsed(TtmlNode node);
}

/** @hide */
class TtmlTrack extends SubtitleTrack implements TtmlNodeListener {
    private static final String TAG = "TtmlTrack";

    private final TtmlParser mParser = new TtmlParser(this);
    private final TtmlRenderingWidget mRenderingWidget;
    private String mParsingData;
    private Long mCurrentRunID;

    private final LinkedList<TtmlNode> mTtmlNodes;
    private final TreeSet<Long> mTimeEvents;
    private TtmlNode mRootNode;

    TtmlTrack(TtmlRenderingWidget renderingWidget, MediaFormat format) {
        super(format);

        mTtmlNodes = new LinkedList<TtmlNode>();
        mTimeEvents = new TreeSet<Long>();
        mRenderingWidget = renderingWidget;
        mParsingData = "";
    }

    @Override
    public TtmlRenderingWidget getRenderingWidget() {
        return mRenderingWidget;
    }

    @Override
    public void onData(byte[] data, boolean eos, long runID) {
        try {
            // TODO: handle UTF-8 conversion properly
            String str = new String(data, "UTF-8");

            // implement intermixing restriction for TTML.
            synchronized(mParser) {
                if (mCurrentRunID != null && runID != mCurrentRunID) {
                    throw new IllegalStateException(
                            "Run #" + mCurrentRunID +
                            " in progress.  Cannot process run #" + runID);
                }
                mCurrentRunID = runID;
                mParsingData += str;
                if (eos) {
                    try {
                        mParser.parse(mParsingData, mCurrentRunID);
                    } catch (XmlPullParserException e) {
                        e.printStackTrace();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                    finishedRun(runID);
                    mParsingData = "";
                    mCurrentRunID = null;
                }
            }
        } catch (java.io.UnsupportedEncodingException e) {
            Log.w(TAG, "subtitle data is not UTF-8 encoded: " + e);
        }
    }

    @Override
    public void onTtmlNodeParsed(TtmlNode node) {
        mTtmlNodes.addLast(node);
        addTimeEvents(node);
    }

    @Override
    public void onRootNodeParsed(TtmlNode node) {
        mRootNode = node;
        TtmlCue cue = null;
        while ((cue = getNextResult()) != null) {
            addCue(cue);
        }
        mRootNode = null;
        mTtmlNodes.clear();
        mTimeEvents.clear();
    }

    @Override
    public void updateView(Vector<SubtitleTrack.Cue> activeCues) {
        if (!mVisible) {
            // don't keep the state if we are not visible
            return;
        }

        if (DEBUG && mTimeProvider != null) {
            try {
                Log.d(TAG, "at " +
                        (mTimeProvider.getCurrentTimeUs(false, true) / 1000) +
                        " ms the active cues are:");
            } catch (IllegalStateException e) {
                Log.d(TAG, "at (illegal state) the active cues are:");
            }
        }

        mRenderingWidget.setActiveCues(activeCues);
    }

    /**
     * Returns a {@link TtmlCue} in the presentation time order.
     * {@code null} is returned if there is no more timed text to show.
     */
    public TtmlCue getNextResult() {
        while (mTimeEvents.size() >= 2) {
            long start = mTimeEvents.pollFirst();
            long end = mTimeEvents.first();
            List<TtmlNode> activeCues = getActiveNodes(start, end);
            if (!activeCues.isEmpty()) {
                return new TtmlCue(start, end,
                        TtmlUtils.applySpacePolicy(TtmlUtils.extractText(
                                mRootNode, start, end), false),
                        TtmlUtils.extractTtmlFragment(mRootNode, start, end));
            }
        }
        return null;
    }

    private void addTimeEvents(TtmlNode node) {
        mTimeEvents.add(node.mStartTimeMs);
        mTimeEvents.add(node.mEndTimeMs);
        for (int i = 0; i < node.mChildren.size(); ++i) {
            addTimeEvents(node.mChildren.get(i));
        }
    }

    private List<TtmlNode> getActiveNodes(long startTimeUs, long endTimeUs) {
        List<TtmlNode> activeNodes = new ArrayList<TtmlNode>();
        for (int i = 0; i < mTtmlNodes.size(); ++i) {
            TtmlNode node = mTtmlNodes.get(i);
            if (node.isActive(startTimeUs, endTimeUs)) {
                activeNodes.add(node);
            }
        }
        return activeNodes;
    }
}

/**
 * Widget capable of rendering TTML captions.
 *
 * @hide
 */
class TtmlRenderingWidget extends LinearLayout implements SubtitleTrack.RenderingWidget {

    /** Callback for rendering changes. */
    private OnChangedListener mListener;
    private final TextView mTextView;

    public TtmlRenderingWidget(Context context) {
        this(context, null);
    }

    public TtmlRenderingWidget(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TtmlRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public TtmlRenderingWidget(Context context, AttributeSet attrs, int defStyleAttr,
            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        // Cannot render text over video when layer type is hardware.
        setLayerType(View.LAYER_TYPE_SOFTWARE, null);

        CaptioningManager captionManager = (CaptioningManager) context.getSystemService(
                Context.CAPTIONING_SERVICE);
        mTextView = new TextView(context);
        mTextView.setTextColor(captionManager.getUserStyle().foregroundColor);
        addView(mTextView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        mTextView.setGravity(Gravity.BOTTOM | Gravity.CENTER_HORIZONTAL);
    }

    @Override
    public void setOnChangedListener(OnChangedListener listener) {
        mListener = listener;
    }

    @Override
    public void setSize(int width, int height) {
        final int widthSpec = MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY);
        final int heightSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);

        measure(widthSpec, heightSpec);
        layout(0, 0, width, height);
    }

    @Override
    public void setVisible(boolean visible) {
        if (visible) {
            setVisibility(View.VISIBLE);
        } else {
            setVisibility(View.GONE);
        }
    }

    @Override
    public void onAttachedToWindow() {
        super.onAttachedToWindow();
    }

    @Override
    public void onDetachedFromWindow() {
        super.onDetachedFromWindow();
    }

    public void setActiveCues(Vector<SubtitleTrack.Cue> activeCues) {
        final int count = activeCues.size();
        String subtitleText = "";
        for (int i = 0; i < count; i++) {
            TtmlCue cue = (TtmlCue) activeCues.get(i);
            subtitleText += cue.mText + "\n";
        }
        mTextView.setText(subtitleText);

        if (mListener != null) {
            mListener.onChanged(this);
        }
    }
}