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