[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.
+''';