[video_player] VTT Support (#2878)

diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index de60af4..539a552 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.2.5
+
+* Support to closed caption WebVTT format added.
+
 ## 2.2.4
 
 * Update minimum Flutter SDK to 2.5 and iOS deployment target to 9.0.
diff --git a/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt
new file mode 100644
index 0000000..1dca2c5
--- /dev/null
+++ b/packages/video_player/video_player/example/assets/bumble_bee_captions.vtt
@@ -0,0 +1,7 @@
+WEBVTT
+
+00:00:00.200 --> 00:00:01.750
+[ Birds chirping ]
+
+00:00:02.300 --> 00:00:05.000
+[ Buzzing ]
diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart
index eef2319..f035720 100644
--- a/packages/video_player/video_player/example/lib/main.dart
+++ b/packages/video_player/video_player/example/lib/main.dart
@@ -210,8 +210,9 @@
 
   Future<ClosedCaptionFile> _loadCaptions() async {
     final String fileContents = await DefaultAssetBundle.of(context)
-        .loadString('assets/bumble_bee_captions.srt');
-    return SubRipCaptionFile(fileContents);
+        .loadString('assets/bumble_bee_captions.vtt');
+    return WebVTTCaptionFile(
+        fileContents); // For vtt files, use WebVTTCaptionFile
   }
 
   @override
diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml
index 63f179a..0539f3c 100644
--- a/packages/video_player/video_player/example/pubspec.yaml
+++ b/packages/video_player/video_player/example/pubspec.yaml
@@ -30,6 +30,7 @@
 flutter:
   uses-material-design: true
   assets:
-   - assets/flutter-mark-square-64.png
-   - assets/Butterfly-209.mp4
-   - assets/bumble_bee_captions.srt
+    - assets/flutter-mark-square-64.png
+    - assets/Butterfly-209.mp4
+    - assets/bumble_bee_captions.srt
+    - assets/bumble_bee_captions.vtt
diff --git a/packages/video_player/video_player/lib/src/closed_caption_file.dart b/packages/video_player/video_player/lib/src/closed_caption_file.dart
index 3c7d69b..e410e26 100644
--- a/packages/video_player/video_player/lib/src/closed_caption_file.dart
+++ b/packages/video_player/video_player/lib/src/closed_caption_file.dart
@@ -5,6 +5,9 @@
 import 'sub_rip.dart';
 export 'sub_rip.dart' show SubRipCaptionFile;
 
+import 'web_vtt.dart';
+export 'web_vtt.dart' show WebVTTCaptionFile;
+
 /// A structured representation of a parsed closed caption file.
 ///
 /// A closed caption file includes a list of captions, each with a start and end
@@ -15,6 +18,7 @@
 ///
 /// See:
 /// * [SubRipCaptionFile].
+/// * [WebVTTCaptionFile].
 abstract class ClosedCaptionFile {
   /// The full list of captions from a given file.
   ///
diff --git a/packages/video_player/video_player/lib/src/sub_rip.dart b/packages/video_player/video_player/lib/src/sub_rip.dart
index 73cd826..5d6863f 100644
--- a/packages/video_player/video_player/lib/src/sub_rip.dart
+++ b/packages/video_player/video_player/lib/src/sub_rip.dart
@@ -16,6 +16,8 @@
       : _captions = _parseCaptionsFromSubRipString(fileContents);
 
   /// The entire body of the SubRip file.
+  // TODO(cyanglaz): Remove this public member as it doesn't seem need to exist.
+  // https://github.com/flutter/flutter/issues/90471
   final String fileContents;
 
   @override
@@ -30,15 +32,15 @@
     if (captionLines.length < 3) break;
 
     final int captionNumber = int.parse(captionLines[0]);
-    final _StartAndEnd startAndEnd =
-        _StartAndEnd.fromSubRipString(captionLines[1]);
+    final _CaptionRange captionRange =
+        _CaptionRange.fromSubRipString(captionLines[1]);
 
     final String text = captionLines.sublist(2).join('\n');
 
     final Caption newCaption = Caption(
       number: captionNumber,
-      start: startAndEnd.start,
-      end: startAndEnd.end,
+      start: captionRange.start,
+      end: captionRange.end,
       text: text,
     );
     if (newCaption.start != newCaption.end) {
@@ -49,21 +51,21 @@
   return captions;
 }
 
-class _StartAndEnd {
+class _CaptionRange {
   final Duration start;
   final Duration end;
 
-  _StartAndEnd(this.start, this.end);
+  _CaptionRange(this.start, this.end);
 
   // Assumes format from an SubRip file.
   // For example:
   // 00:01:54,724 --> 00:01:56,760
-  static _StartAndEnd fromSubRipString(String line) {
+  static _CaptionRange fromSubRipString(String line) {
     final RegExp format =
         RegExp(_subRipTimeStamp + _subRipArrow + _subRipTimeStamp);
 
     if (!format.hasMatch(line)) {
-      return _StartAndEnd(Duration.zero, Duration.zero);
+      return _CaptionRange(Duration.zero, Duration.zero);
     }
 
     final List<String> times = line.split(_subRipArrow);
@@ -71,7 +73,7 @@
     final Duration start = _parseSubRipTimestamp(times[0]);
     final Duration end = _parseSubRipTimestamp(times[1]);
 
-    return _StartAndEnd(start, end);
+    return _CaptionRange(start, end);
   }
 }
 
diff --git a/packages/video_player/video_player/lib/src/web_vtt.dart b/packages/video_player/video_player/lib/src/web_vtt.dart
new file mode 100644
index 0000000..6c4527d
--- /dev/null
+++ b/packages/video_player/video_player/lib/src/web_vtt.dart
@@ -0,0 +1,211 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:html/dom.dart';
+
+import 'closed_caption_file.dart';
+import 'package:html/parser.dart' as html_parser;
+
+/// Represents a [ClosedCaptionFile], parsed from the WebVTT file format.
+/// See: https://en.wikipedia.org/wiki/WebVTT
+class WebVTTCaptionFile extends ClosedCaptionFile {
+  /// Parses a string into a [ClosedCaptionFile], assuming [fileContents] is in
+  /// the WebVTT file format.
+  /// * See: https://en.wikipedia.org/wiki/WebVTT
+  WebVTTCaptionFile(String fileContents)
+      : _captions = _parseCaptionsFromWebVTTString(fileContents);
+
+  @override
+  List<Caption> get captions => _captions;
+
+  final List<Caption> _captions;
+}
+
+List<Caption> _parseCaptionsFromWebVTTString(String file) {
+  final List<Caption> captions = <Caption>[];
+
+  // Ignore metadata
+  Set<String> metadata = {'HEADER', 'NOTE', 'REGION', 'WEBVTT'};
+
+  int captionNumber = 1;
+  for (List<String> captionLines in _readWebVTTFile(file)) {
+    // CaptionLines represent a complete caption.
+    // E.g
+    // [
+    //  [00:00.000 --> 01:24.000 align:center]
+    //  ['Introduction']
+    // ]
+    // If caption has just header or time, but no text, `captionLines.length` will be 1.
+    if (captionLines.length < 2) continue;
+
+    // If caption has header equal metadata, ignore.
+    String metadaType = captionLines[0].split(' ')[0];
+    if (metadata.contains(metadaType)) continue;
+
+    // Caption has header
+    bool hasHeader = captionLines.length > 2;
+    if (hasHeader) {
+      final int? tryParseCaptionNumber = int.tryParse(captionLines[0]);
+      if (tryParseCaptionNumber != null) {
+        captionNumber = tryParseCaptionNumber;
+      }
+    }
+
+    final _CaptionRange? captionRange = _CaptionRange.fromWebVTTString(
+      hasHeader ? captionLines[1] : captionLines[0],
+    );
+
+    if (captionRange == null) {
+      continue;
+    }
+
+    final String text = captionLines.sublist(hasHeader ? 2 : 1).join('\n');
+
+    // TODO(cyanglaz): Handle special syntax in VTT captions.
+    // https://github.com/flutter/flutter/issues/90007.
+    final String textWithoutFormat = _extractTextFromHtml(text);
+
+    final Caption newCaption = Caption(
+      number: captionNumber,
+      start: captionRange.start,
+      end: captionRange.end,
+      text: textWithoutFormat,
+    );
+    captions.add(newCaption);
+    captionNumber++;
+  }
+
+  return captions;
+}
+
+class _CaptionRange {
+  final Duration start;
+  final Duration end;
+
+  _CaptionRange(this.start, this.end);
+
+  // Assumes format from an VTT file.
+  // For example:
+  // 00:09.000 --> 00:11.000
+  static _CaptionRange? fromWebVTTString(String line) {
+    final RegExp format =
+        RegExp(_webVTTTimeStamp + _webVTTArrow + _webVTTTimeStamp);
+
+    if (!format.hasMatch(line)) {
+      return null;
+    }
+
+    final List<String> times = line.split(_webVTTArrow);
+
+    final Duration? start = _parseWebVTTTimestamp(times[0]);
+    final Duration? end = _parseWebVTTTimestamp(times[1]);
+
+    if (start == null || end == null) {
+      return null;
+    }
+
+    return _CaptionRange(start, end);
+  }
+}
+
+String _extractTextFromHtml(String htmlString) {
+  final Document document = html_parser.parse(htmlString);
+  final Element? body = document.body;
+  if (body == null) {
+    return '';
+  }
+  final Element? bodyElement = html_parser.parse(body.text).documentElement;
+  return bodyElement?.text ?? '';
+}
+
+// Parses a time stamp in an VTT file into a Duration.
+//
+// Returns `null` if `timestampString` is in an invalid format.
+//
+// For example:
+//
+// _parseWebVTTTimestamp('00:01:08.430')
+// returns
+// Duration(hours: 0, minutes: 1, seconds: 8, milliseconds: 430)
+Duration? _parseWebVTTTimestamp(String timestampString) {
+  if (!RegExp(_webVTTTimeStamp).hasMatch(timestampString)) {
+    return null;
+  }
+
+  final List<String> dotSections = timestampString.split('.');
+  final List<String> timeComponents = dotSections[0].split(':');
+
+  // Validating and parsing the `timestampString`, invalid format will result this method
+  // to return `null`. See https://www.w3.org/TR/webvtt1/#webvtt-timestamp for valid
+  // WebVTT timestamp format.
+  if (timeComponents.length > 3 || timeComponents.length < 2) {
+    return null;
+  }
+  int hours = 0;
+  if (timeComponents.length == 3) {
+    final String hourString = timeComponents.removeAt(0);
+    if (hourString.length < 2) {
+      return null;
+    }
+    hours = int.parse(hourString);
+  }
+  final int minutes = int.parse(timeComponents.removeAt(0));
+  if (minutes < 0 || minutes > 59) {
+    return null;
+  }
+  final int seconds = int.parse(timeComponents.removeAt(0));
+  if (seconds < 0 || seconds > 59) {
+    return null;
+  }
+
+  List<String> milisecondsStyles = dotSections[1].split(" ");
+
+  // TODO(cyanglaz): Handle caption styles.
+  // https://github.com/flutter/flutter/issues/90009.
+  // ```dart
+  // if (milisecondsStyles.length > 1) {
+  //  List<String> styles = milisecondsStyles.sublist(1);
+  // }
+  // ```
+  // For a better readable code style, style parsing should happen before
+  // calling this method. See: https://github.com/flutter/plugins/pull/2878/files#r713381134.
+  int milliseconds = int.parse(milisecondsStyles[0]);
+
+  return Duration(
+    hours: hours,
+    minutes: minutes,
+    seconds: seconds,
+    milliseconds: milliseconds,
+  );
+}
+
+// Reads on VTT file and splits it into Lists of strings where each list is one
+// caption.
+List<List<String>> _readWebVTTFile(String file) {
+  final List<String> lines = LineSplitter.split(file).toList();
+
+  final List<List<String>> captionStrings = <List<String>>[];
+  List<String> currentCaption = <String>[];
+  int lineIndex = 0;
+  for (final String line in lines) {
+    final bool isLineBlank = line.trim().isEmpty;
+    if (!isLineBlank) {
+      currentCaption.add(line);
+    }
+
+    if (isLineBlank || lineIndex == lines.length - 1) {
+      captionStrings.add(currentCaption);
+      currentCaption = <String>[];
+    }
+
+    lineIndex += 1;
+  }
+
+  return captionStrings;
+}
+
+const String _webVTTTimeStamp = r'(\d+):(\d{2})(:\d{2})?\.(\d{3})';
+const String _webVTTArrow = r' --> ';
diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml
index 926add5..a6ee2d5 100644
--- a/packages/video_player/video_player/pubspec.yaml
+++ b/packages/video_player/video_player/pubspec.yaml
@@ -3,7 +3,7 @@
   widgets on Android, iOS, and web.
 repository: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
-version: 2.2.4
+version: 2.2.5
 
 environment:
   sdk: ">=2.14.0 <3.0.0"
@@ -32,6 +32,7 @@
   # TODO(amirh): Revisit this (either update this part in the design or the pub tool).
   # https://github.com/flutter/flutter/issues/46264
   video_player_web: ^2.0.0
+  html: ^0.15.0
 
 dev_dependencies:
   flutter_test:
diff --git a/packages/video_player/video_player/test/web_vtt_test.dart b/packages/video_player/video_player/test/web_vtt_test.dart
new file mode 100644
index 0000000..59fce98
--- /dev/null
+++ b/packages/video_player/video_player/test/web_vtt_test.dart
@@ -0,0 +1,261 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_test/flutter_test.dart';
+import 'package:video_player/src/closed_caption_file.dart';
+import 'package:video_player/video_player.dart';
+
+void main() {
+  group('Parse VTT file', () {
+    WebVTTCaptionFile parsedFile;
+
+    test('with Metadata', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_with_metadata);
+      expect(parsedFile.captions.length, 1);
+
+      expect(parsedFile.captions[0].start, Duration(seconds: 1));
+      expect(
+          parsedFile.captions[0].end, Duration(seconds: 2, milliseconds: 500));
+      expect(parsedFile.captions[0].text, 'We are in New York City');
+    });
+
+    test('with Multiline', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_with_multiline);
+      expect(parsedFile.captions.length, 1);
+
+      expect(parsedFile.captions[0].start,
+          Duration(seconds: 2, milliseconds: 800));
+      expect(
+          parsedFile.captions[0].end, Duration(seconds: 3, milliseconds: 283));
+      expect(parsedFile.captions[0].text,
+          "— It will perforate your stomach.\n— You could die.");
+    });
+
+    test('with styles tags', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_with_styles);
+      expect(parsedFile.captions.length, 3);
+
+      expect(parsedFile.captions[0].start,
+          Duration(seconds: 5, milliseconds: 200));
+      expect(
+          parsedFile.captions[0].end, Duration(seconds: 6, milliseconds: 000));
+      expect(parsedFile.captions[0].text,
+          "You know I'm so excited my glasses are falling off here.");
+    });
+
+    test('with subtitling features', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_with_subtitling_features);
+      expect(parsedFile.captions.length, 3);
+
+      expect(parsedFile.captions[0].number, 1);
+      expect(parsedFile.captions.last.start, Duration(seconds: 4));
+      expect(parsedFile.captions.last.end, Duration(seconds: 5));
+      expect(parsedFile.captions.last.text, "Transcrit par Célestes™");
+    });
+
+    test('with [hours]:[minutes]:[seconds].[milliseconds].', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_with_hours);
+      expect(parsedFile.captions.length, 1);
+
+      expect(parsedFile.captions[0].number, 1);
+      expect(parsedFile.captions.last.start, Duration(seconds: 1));
+      expect(parsedFile.captions.last.end, Duration(seconds: 2));
+      expect(parsedFile.captions.last.text, "This is a test.");
+    });
+
+    test('with [minutes]:[seconds].[milliseconds].', () {
+      parsedFile = WebVTTCaptionFile(_valid_vtt_without_hours);
+      expect(parsedFile.captions.length, 1);
+
+      expect(parsedFile.captions[0].number, 1);
+      expect(parsedFile.captions.last.start, Duration(seconds: 3));
+      expect(parsedFile.captions.last.end, Duration(seconds: 4));
+      expect(parsedFile.captions.last.text, "This is a test.");
+    });
+
+    test('with invalid seconds format returns empty captions.', () {
+      parsedFile = WebVTTCaptionFile(_invalid_seconds);
+      expect(parsedFile.captions, isEmpty);
+    });
+
+    test('with invalid minutes format returns empty captions.', () {
+      parsedFile = WebVTTCaptionFile(_invalid_minutes);
+      expect(parsedFile.captions, isEmpty);
+    });
+
+    test('with invalid hours format returns empty captions.', () {
+      parsedFile = WebVTTCaptionFile(_invalid_hours);
+      expect(parsedFile.captions, isEmpty);
+    });
+
+    test('with invalid component length returns empty captions.', () {
+      parsedFile = WebVTTCaptionFile(_time_component_too_long);
+      expect(parsedFile.captions, isEmpty);
+
+      parsedFile = WebVTTCaptionFile(_time_component_too_short);
+      expect(parsedFile.captions, isEmpty);
+    });
+  });
+
+  test('Parses VTT file with malformed input.', () {
+    final ClosedCaptionFile parsedFile = WebVTTCaptionFile(_malformedVTT);
+
+    expect(parsedFile.captions.length, 1);
+
+    final Caption firstCaption = parsedFile.captions.single;
+    expect(firstCaption.number, 1);
+    expect(firstCaption.start, Duration(seconds: 13));
+    expect(firstCaption.end, Duration(seconds: 16, milliseconds: 0));
+    expect(firstCaption.text, 'Valid');
+  });
+}
+
+/// See https://www.w3.org/TR/webvtt1/#introduction-comments
+const String _valid_vtt_with_metadata = '''
+WEBVTT Kind: captions; Language: en
+
+REGION
+id:bill
+width:40%
+lines:3
+regionanchor:100%,100%
+viewportanchor:90%,90%
+scroll:up
+
+NOTE
+This file was written by Jill. I hope
+you enjoy reading it. Some things to
+bear in mind:
+- I was lip-reading, so the cues may
+not be 100% accurate
+- I didn’t pay too close attention to
+when the cues should start or end.
+
+1
+00:01.000 --> 00:02.500
+<v Roger Bingham>We are in New York City
+''';
+
+/// See https://www.w3.org/TR/webvtt1/#introduction-multiple-lines
+const String _valid_vtt_with_multiline = '''
+WEBVTT
+
+2
+00:02.800 --> 00:03.283
+— It will perforate your stomach.
+— You could die.
+
+''';
+
+/// See https://www.w3.org/TR/webvtt1/#styling
+const String _valid_vtt_with_styles = '''
+WEBVTT
+
+00:05.200 --> 00:06.000 align:start size:50%
+<v Roger Bingham><i>You know I'm so excited my glasses are falling off here.</i>
+
+00:00:06.050 --> 00:00:06.150 
+<v Roger Bingham><i>I have a different time!</i>
+
+00:06.200 --> 00:06.900
+<c.yellow.bg_blue>This is yellow text on a blue background</c>
+
+''';
+
+//See https://www.w3.org/TR/webvtt1/#introduction-other-features
+const String _valid_vtt_with_subtitling_features = '''
+WEBVTT
+
+test
+00:00.000 --> 00:02.000
+This is a test.
+
+Slide 1
+00:00:00.000 --> 00:00:10.700
+Title Slide
+
+crédit de transcription
+00:04.000 --> 00:05.000
+Transcrit par Célestes™
+
+''';
+
+/// With format [hours]:[minutes]:[seconds].[milliseconds]
+const String _valid_vtt_with_hours = '''
+WEBVTT
+
+test
+00:00:01.000 --> 00:00:02.000
+This is a test.
+
+''';
+
+/// Invalid seconds format.
+const String _invalid_seconds = '''
+WEBVTT
+
+60:00:000.000 --> 60:02:000.000
+This is a test.
+
+''';
+
+/// Invalid minutes format.
+const String _invalid_minutes = '''
+WEBVTT
+
+60:60:00.000 --> 60:70:00.000
+This is a test.
+
+''';
+
+/// Invalid hours format.
+const String _invalid_hours = '''
+WEBVTT
+
+5:00:00.000 --> 5:02:00.000
+This is a test.
+
+''';
+
+/// Invalid seconds format.
+const String _time_component_too_long = '''
+WEBVTT
+
+60:00:00:00.000 --> 60:02:00:00.000
+This is a test.
+
+''';
+
+/// Invalid seconds format.
+const String _time_component_too_short = '''
+WEBVTT
+
+60:00.000 --> 60:02.000
+This is a test.
+
+''';
+
+/// With format [minutes]:[seconds].[milliseconds]
+const String _valid_vtt_without_hours = '''
+WEBVTT
+
+00:03.000 --> 00:04.000
+This is a test.
+
+''';
+
+const String _malformedVTT = '''
+
+WEBVTT Kind: captions; Language: en
+
+00:09.000--> 00:11.430
+<Test>This one should be ignored because the arrow needs a space.
+
+00:13.000 --> 00:16.000
+<Test>Valid
+
+00:16.000 --> 00:8.000
+<Test>This one should be ignored because the time is missing a digit.
+
+''';