[Closed Captioning] Create SubRip file parser and dart closed caption data object (#2473)

This PR specifies a dart object that represents a "Closed Caption". This will be useful in a follow up PR, where I will add closed caption support the the `VideoPlayerController`. 
diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index 2c9c5dd..ae55a80 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.10.6
+
+* `ClosedCaptionFile` and `SubRipCaptionFile` classes added to read
+  [SubRip](https://en.wikipedia.org/wiki/SubRip) files into dart objects.
+
 ## 0.10.5+3
 
 * Add integration instructions for the `web` platform.
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
new file mode 100644
index 0000000..2d9242a
--- /dev/null
+++ b/packages/video_player/video_player/lib/src/closed_caption_file.dart
@@ -0,0 +1,48 @@
+// Copyright 2020 The Chromium 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 'sub_rip.dart';
+export 'sub_rip.dart' show SubRipCaptionFile;
+
+/// A structured representation of a parsed closed caption file.
+///
+/// A closed caption file includes a list of captions, each with a start and end
+/// time for when the given closed caption should be displayed.
+///
+/// The [captions] are a list of all captions in a file, in the order that they
+/// appeared in the file.
+///
+/// See:
+/// * [SubRipCaptionFile].
+abstract class ClosedCaptionFile {
+  /// The full list of captions from a given file.
+  ///
+  /// The [captions] will be in the order that they appear in the given file.
+  List<Caption> get captions;
+}
+
+/// A representation of a single caption.
+///
+/// A typical closed captioning file will include several [Caption]s, each
+/// linked to a start and end time.
+class Caption {
+  /// Creates a new [Caption] object.
+  ///
+  /// This is not recommended for direct use unless you are writing a parser for
+  /// a new closed captioning file type.
+  const Caption({this.number, this.start, this.end, this.text});
+
+  /// The number that this caption was assigned.
+  final int number;
+
+  /// When in the given video should this [Caption] begin displaying.
+  final Duration start;
+
+  /// When in the given video should this [Caption] be dismissed.
+  final Duration end;
+
+  /// The actual text that should appear on screen to be read between [start]
+  /// and [end].
+  final String text;
+}
diff --git a/packages/video_player/video_player/lib/src/sub_rip.dart b/packages/video_player/video_player/lib/src/sub_rip.dart
new file mode 100644
index 0000000..15dc43c
--- /dev/null
+++ b/packages/video_player/video_player/lib/src/sub_rip.dart
@@ -0,0 +1,132 @@
+// Copyright 2020 The Chromium 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 'closed_caption_file.dart';
+
+/// Represents a [ClosedCaptionFile], parsed from the SubRip file format.
+/// See: https://en.wikipedia.org/wiki/SubRip
+class SubRipCaptionFile extends ClosedCaptionFile {
+  /// Parses a string into a [ClosedCaptionFile], assuming [fileContents] is in
+  /// the SubRip file format.
+  /// * See: https://en.wikipedia.org/wiki/SubRip
+  SubRipCaptionFile(this.fileContents)
+      : _captions = _parseCaptionsFromSubRipString(fileContents);
+
+  /// The entire body of the SubRip file.
+  final String fileContents;
+
+  @override
+  List<Caption> get captions => _captions;
+
+  final List<Caption> _captions;
+}
+
+List<Caption> _parseCaptionsFromSubRipString(String file) {
+  final List<Caption> captions = <Caption>[];
+  for (List<String> captionLines in _readSubRipFile(file)) {
+    if (captionLines.length < 3) break;
+
+    final int captionNumber = int.parse(captionLines[0]);
+    final _StartAndEnd startAndEnd =
+        _StartAndEnd.fromSubRipString(captionLines[1]);
+
+    final String text = captionLines.sublist(2).join('\n');
+
+    final Caption newCaption = Caption(
+      number: captionNumber,
+      start: startAndEnd.start,
+      end: startAndEnd.end,
+      text: text,
+    );
+
+    if (newCaption.start != null && newCaption.end != null) {
+      captions.add(newCaption);
+    }
+  }
+
+  return captions;
+}
+
+class _StartAndEnd {
+  final Duration start;
+  final Duration end;
+
+  _StartAndEnd(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) {
+    final RegExp format =
+        RegExp(_subRipTimeStamp + _subRipArrow + _subRipTimeStamp);
+
+    if (!format.hasMatch(line)) {
+      return _StartAndEnd(null, null);
+    }
+
+    final List<String> times = line.split(_subRipArrow);
+
+    final Duration start = _parseSubRipTimestamp(times[0]);
+    final Duration end = _parseSubRipTimestamp(times[1]);
+
+    return _StartAndEnd(start, end);
+  }
+}
+
+// Parses a time stamp in an SubRip file into a Duration.
+// For example:
+//
+// _parseSubRipTimestamp('00:01:59,084')
+// returns
+// Duration(hours: 0, minutes: 1, seconds: 59, milliseconds: 084)
+Duration _parseSubRipTimestamp(String timestampString) {
+  if (!RegExp(_subRipTimeStamp).hasMatch(timestampString)) {
+    return null;
+  }
+
+  final List<String> commaSections = timestampString.split(',');
+  final List<String> hoursMinutesSeconds = commaSections[0].split(':');
+
+  final int hours = int.parse(hoursMinutesSeconds[0]);
+  final int minutes = int.parse(hoursMinutesSeconds[1]);
+  final int seconds = int.parse(hoursMinutesSeconds[2]);
+  final int milliseconds = int.parse(commaSections[1]);
+
+  return Duration(
+    hours: hours,
+    minutes: minutes,
+    seconds: seconds,
+    milliseconds: milliseconds,
+  );
+}
+
+// Reads on SubRip file and splits it into Lists of strings where each list is one
+// caption.
+List<List<String>> _readSubRipFile(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 _subRipTimeStamp = r'\d\d:\d\d:\d\d,\d\d\d';
+const String _subRipArrow = r' --> ';
diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart
index b5527b9..97b87b2 100644
--- a/packages/video_player/video_player/lib/video_player.dart
+++ b/packages/video_player/video_player/lib/video_player.dart
@@ -14,6 +14,8 @@
 export 'package:video_player_platform_interface/video_player_platform_interface.dart'
     show DurationRange, DataSourceType, VideoFormat;
 
+export 'src/closed_caption_file.dart';
+
 final VideoPlayerPlatform _videoPlayerPlatform = VideoPlayerPlatform.instance
   // This will clear all open videos on the platform when a full restart is
   // performed.
diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml
index 724f066..33aa863 100644
--- a/packages/video_player/video_player/pubspec.yaml
+++ b/packages/video_player/video_player/pubspec.yaml
@@ -1,7 +1,7 @@
 name: video_player
 description: Flutter plugin for displaying inline video with other Flutter
   widgets on Android and iOS.
-version: 0.10.5+3
+version: 0.10.6
 homepage: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player
 
 flutter:
diff --git a/packages/video_player/video_player/test/sub_rip_file_test.dart b/packages/video_player/video_player/test/sub_rip_file_test.dart
new file mode 100644
index 0000000..cf25ff7
--- /dev/null
+++ b/packages/video_player/video_player/test/sub_rip_file_test.dart
@@ -0,0 +1,113 @@
+// Copyright 2020 The Chromium 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() {
+  test('Parses SubRip file', () {
+    final SubRipCaptionFile parsedFile = SubRipCaptionFile(_validSubRip);
+
+    expect(parsedFile.captions.length, 4);
+
+    final Caption firstCaption = parsedFile.captions.first;
+    expect(firstCaption.number, 1);
+    expect(firstCaption.start, Duration(seconds: 6));
+    expect(firstCaption.end, Duration(seconds: 12, milliseconds: 74));
+    expect(firstCaption.text, 'This is a test file');
+
+    final Caption secondCaption = parsedFile.captions[1];
+    expect(secondCaption.number, 2);
+    expect(
+      secondCaption.start,
+      Duration(minutes: 1, seconds: 54, milliseconds: 724),
+    );
+    expect(
+      secondCaption.end,
+      Duration(minutes: 1, seconds: 56, milliseconds: 760),
+    );
+    expect(secondCaption.text, '- Hello.\n- Yes?');
+
+    final Caption thirdCaption = parsedFile.captions[2];
+    expect(thirdCaption.number, 3);
+    expect(
+      thirdCaption.start,
+      Duration(minutes: 1, seconds: 56, milliseconds: 884),
+    );
+    expect(
+      thirdCaption.end,
+      Duration(minutes: 1, seconds: 58, milliseconds: 954),
+    );
+    expect(
+      thirdCaption.text,
+      'These are more test lines\nYes, these are more test lines.',
+    );
+
+    final Caption fourthCaption = parsedFile.captions[3];
+    expect(fourthCaption.number, 4);
+    expect(
+      fourthCaption.start,
+      Duration(hours: 1, minutes: 1, seconds: 59, milliseconds: 84),
+    );
+    expect(
+      fourthCaption.end,
+      Duration(hours: 1, minutes: 2, seconds: 1, milliseconds: 552),
+    );
+    expect(
+      fourthCaption.text,
+      '- [ Machinery Beeping ]\n- I\'m not sure what that was,',
+    );
+  });
+
+  test('Parses SubRip file with malformed input', () {
+    final ClosedCaptionFile parsedFile = SubRipCaptionFile(_malformedSubRip);
+
+    expect(parsedFile.captions.length, 1);
+
+    final Caption firstCaption = parsedFile.captions.single;
+    expect(firstCaption.number, 2);
+    expect(firstCaption.start, Duration(seconds: 15));
+    expect(firstCaption.end, Duration(seconds: 17, milliseconds: 74));
+    expect(firstCaption.text, 'This one is valid');
+  });
+}
+
+const String _validSubRip = '''
+1
+00:00:06,000 --> 00:00:12,074
+This is a test file
+
+2
+00:01:54,724 --> 00:01:56,760
+- Hello.
+- Yes?
+
+3
+00:01:56,884 --> 00:01:58,954
+These are more test lines
+Yes, these are more test lines.
+
+4
+01:01:59,084 --> 01:02:01,552
+- [ Machinery Beeping ]
+- I'm not sure what that was,
+
+''';
+
+const String _malformedSubRip = '''
+1
+00:00:06,000--> 00:00:12,074
+This one should be ignored because the
+arrow needs a space.
+
+2
+00:00:15,000 --> 00:00:17,074
+This one is valid
+
+3
+00:01:54,724 --> 00:01:6,760
+This one should be ignored because the 
+ned time is missing a digit.
+''';