diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index de60af49b95d..539a5520e5be 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 000000000000..1dca2c58695e --- /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 eef23197ef50..f035720396dd 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 @@ class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> { Future _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 63f179a06211..0539f3c6f56c 100644 --- a/packages/video_player/video_player/example/pubspec.yaml +++ b/packages/video_player/video_player/example/pubspec.yaml @@ -30,6 +30,7 @@ dev_dependencies: 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 3c7d69b89598..e410e2652ad3 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 @@ export 'sub_rip.dart' show SubRipCaptionFile; /// /// 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 73cd8266c2e9..5d6863f72bb8 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 @@ class SubRipCaptionFile extends ClosedCaptionFile { : _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 @@ List _parseCaptionsFromSubRipString(String file) { 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 @@ List _parseCaptionsFromSubRipString(String file) { 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 times = line.split(_subRipArrow); @@ -71,7 +73,7 @@ class _StartAndEnd { 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 000000000000..6c4527d34d67 --- /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 get captions => _captions; + + final List _captions; +} + +List _parseCaptionsFromWebVTTString(String file) { + final List captions = []; + + // Ignore metadata + Set metadata = {'HEADER', 'NOTE', 'REGION', 'WEBVTT'}; + + int captionNumber = 1; + for (List 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 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 dotSections = timestampString.split('.'); + final List 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 milisecondsStyles = dotSections[1].split(" "); + + // TODO(cyanglaz): Handle caption styles. + // https://github.com/flutter/flutter/issues/90009. + // ```dart + // if (milisecondsStyles.length > 1) { + // List 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> _readWebVTTFile(String file) { + final List lines = LineSplitter.split(file).toList(); + + final List> captionStrings = >[]; + List currentCaption = []; + 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 = []; + } + + 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 926add50f43c..a6ee2d594656 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter 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 @@ dependencies: # 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 000000000000..59fce98c5b71 --- /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 +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% +You know I'm so excited my glasses are falling off here. + +00:00:06.050 --> 00:00:06.150 +I have a different time! + +00:06.200 --> 00:06.900 +This is yellow text on a blue background + +'''; + +//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 +This one should be ignored because the arrow needs a space. + +00:13.000 --> 00:16.000 +Valid + +00:16.000 --> 00:8.000 +This one should be ignored because the time is missing a digit. + +''';