[video_player] Add caption to VideoPlayerValue to get the current closed caption (#2487)
This PR adds closed caption support to the `VideoPlayerController` and `VideoPlayerValue`. It also adds a basic widget for displaying closed captioning on top of a `VideoPlayer`.
In addition, this PR:
* Adds tests for the caption functionality.
* Refactors the `example/lib/main.dart` file to include closed caption example, and reduce some of the boiler plate.
* Closes [flutter/flutter#49813](https://github.com/flutter/flutter/issues/49813)
diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index ae55a80..bff37f5 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -1,3 +1,8 @@
+## 0.10.7
+
+* `VideoPlayerController` support for reading closed caption files.
+* `VideoPlayerValue` has a `caption` field for reading the current closed caption at any given time.
+
## 0.10.6
* `ClosedCaptionFile` and `SubRipCaptionFile` classes added to read
diff --git a/packages/video_player/video_player/example/assets/bumble_bee_captions.srt b/packages/video_player/video_player/example/assets/bumble_bee_captions.srt
new file mode 100644
index 0000000..59d749a
--- /dev/null
+++ b/packages/video_player/video_player/example/assets/bumble_bee_captions.srt
@@ -0,0 +1,7 @@
+1
+00:00:00,200 --> 00:00:01,750
+[ Birds chirping ]
+
+2
+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 f55d3f7..bfe81b9 100644
--- a/packages/video_player/video_player/example/lib/main.dart
+++ b/packages/video_player/video_player/example/lib/main.dart
@@ -10,363 +10,16 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
-import 'package:flutter/scheduler.dart';
-/// Controls play and pause of [controller].
-///
-/// Toggles play/pause on tap (accompanied by a fading status icon).
-///
-/// Plays (looping) on initialization, and mutes on deactivation.
-class VideoPlayPause extends StatefulWidget {
- VideoPlayPause(this.controller);
-
- final VideoPlayerController controller;
-
- @override
- State createState() {
- return _VideoPlayPauseState();
- }
-}
-
-class _VideoPlayPauseState extends State<VideoPlayPause> {
- _VideoPlayPauseState() {
- listener = () {
- SchedulerBinding.instance.addPostFrameCallback((_) => setState(() {}));
- };
- }
-
- FadeAnimation imageFadeAnim =
- FadeAnimation(child: const Icon(Icons.play_arrow, size: 100.0));
- VoidCallback listener;
-
- VideoPlayerController get controller => widget.controller;
-
- @override
- void initState() {
- super.initState();
- controller.addListener(listener);
- controller.setVolume(1.0);
- controller.play();
- }
-
- @override
- void deactivate() {
- SchedulerBinding.instance.addPostFrameCallback((_) {
- controller.setVolume(0.0);
- controller.removeListener(listener);
- });
-
- super.deactivate();
- }
-
- @override
- Widget build(BuildContext context) {
- final List<Widget> children = <Widget>[
- GestureDetector(
- child: VideoPlayer(controller),
- onTap: () {
- if (!controller.value.initialized) {
- return;
- }
- if (controller.value.isPlaying) {
- imageFadeAnim =
- FadeAnimation(child: const Icon(Icons.pause, size: 100.0));
- controller.pause();
- } else {
- imageFadeAnim =
- FadeAnimation(child: const Icon(Icons.play_arrow, size: 100.0));
- controller.play();
- }
- },
- ),
- Align(
- alignment: Alignment.bottomCenter,
- child: VideoProgressIndicator(
- controller,
- allowScrubbing: true,
- ),
- ),
- Center(child: imageFadeAnim),
- Center(
- child: controller.value.isBuffering
- ? const CircularProgressIndicator()
- : null),
- ];
-
- return Stack(
- fit: StackFit.passthrough,
- children: children,
- );
- }
-}
-
-class FadeAnimation extends StatefulWidget {
- FadeAnimation(
- {this.child, this.duration = const Duration(milliseconds: 500)});
-
- final Widget child;
- final Duration duration;
-
- @override
- _FadeAnimationState createState() => _FadeAnimationState();
-}
-
-class _FadeAnimationState extends State<FadeAnimation>
- with SingleTickerProviderStateMixin {
- AnimationController animationController;
-
- @override
- void initState() {
- super.initState();
- animationController =
- AnimationController(duration: widget.duration, vsync: this);
- animationController.addListener(() {
- if (mounted) {
- setState(() {});
- }
- });
- animationController.forward(from: 0.0);
- }
-
- @override
- void deactivate() {
- animationController.stop();
- super.deactivate();
- }
-
- @override
- void didUpdateWidget(FadeAnimation oldWidget) {
- super.didUpdateWidget(oldWidget);
- if (oldWidget.child != widget.child) {
- animationController.forward(from: 0.0);
- }
- }
-
- @override
- void dispose() {
- animationController.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return animationController.isAnimating
- ? Opacity(
- opacity: 1.0 - animationController.value,
- child: widget.child,
- )
- : Container();
- }
-}
-
-typedef Widget VideoWidgetBuilder(
- BuildContext context, VideoPlayerController controller);
-
-abstract class PlayerLifeCycle extends StatefulWidget {
- PlayerLifeCycle(this.dataSource, this.childBuilder);
-
- final VideoWidgetBuilder childBuilder;
- final String dataSource;
-}
-
-/// A widget connecting its life cycle to a [VideoPlayerController] using
-/// a data source from the network.
-class NetworkPlayerLifeCycle extends PlayerLifeCycle {
- NetworkPlayerLifeCycle(String dataSource, VideoWidgetBuilder childBuilder)
- : super(dataSource, childBuilder);
-
- @override
- _NetworkPlayerLifeCycleState createState() => _NetworkPlayerLifeCycleState();
-}
-
-/// A widget connecting its life cycle to a [VideoPlayerController] using
-/// an asset as data source
-class AssetPlayerLifeCycle extends PlayerLifeCycle {
- AssetPlayerLifeCycle(String dataSource, VideoWidgetBuilder childBuilder)
- : super(dataSource, childBuilder);
-
- @override
- _AssetPlayerLifeCycleState createState() => _AssetPlayerLifeCycleState();
-}
-
-abstract class _PlayerLifeCycleState extends State<PlayerLifeCycle> {
- VideoPlayerController controller;
-
- @override
-
- /// Subclasses should implement [createVideoPlayerController], which is used
- /// by this method.
- void initState() {
- super.initState();
- controller = createVideoPlayerController();
- controller.addListener(() {
- if (controller.value.hasError) {
- print(controller.value.errorDescription);
- }
- });
- controller.initialize();
- controller.setLooping(true);
- controller.play();
- }
-
- @override
- void deactivate() {
- super.deactivate();
- }
-
- @override
- void dispose() {
- controller.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- return widget.childBuilder(context, controller);
- }
-
- VideoPlayerController createVideoPlayerController();
-}
-
-class _NetworkPlayerLifeCycleState extends _PlayerLifeCycleState {
- @override
- VideoPlayerController createVideoPlayerController() {
- return VideoPlayerController.network(widget.dataSource);
- }
-}
-
-class _AssetPlayerLifeCycleState extends _PlayerLifeCycleState {
- @override
- VideoPlayerController createVideoPlayerController() {
- return VideoPlayerController.asset(widget.dataSource);
- }
-}
-
-/// A filler card to show the video in a list of scrolling contents.
-Widget buildCard(String title) {
- return Card(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: <Widget>[
- ListTile(
- leading: const Icon(Icons.airline_seat_flat_angled),
- title: Text(title),
- ),
- // TODO(jackson): Remove when deprecation is on stable branch
- // ignore: deprecated_member_use
- ButtonTheme.bar(
- child: ButtonBar(
- children: <Widget>[
- FlatButton(
- child: const Text('BUY TICKETS'),
- onPressed: () {
- /* ... */
- },
- ),
- FlatButton(
- child: const Text('SELL TICKETS'),
- onPressed: () {
- /* ... */
- },
- ),
- ],
- ),
- ),
- ],
+void main() {
+ runApp(
+ MaterialApp(
+ home: _App(),
),
);
}
-class VideoInListOfCards extends StatelessWidget {
- VideoInListOfCards(this.controller);
-
- final VideoPlayerController controller;
-
- @override
- Widget build(BuildContext context) {
- return ListView(
- children: <Widget>[
- buildCard("Item a"),
- buildCard("Item b"),
- buildCard("Item c"),
- buildCard("Item d"),
- buildCard("Item e"),
- buildCard("Item f"),
- buildCard("Item g"),
- Card(
- child: Column(children: <Widget>[
- Column(
- children: <Widget>[
- const ListTile(
- leading: Icon(Icons.cake),
- title: Text("Video video"),
- ),
- Stack(
- alignment: FractionalOffset.bottomRight +
- const FractionalOffset(-0.1, -0.1),
- children: <Widget>[
- AspectRatioVideo(controller),
- Image.asset('assets/flutter-mark-square-64.png'),
- ]),
- ],
- ),
- ])),
- buildCard("Item h"),
- buildCard("Item i"),
- buildCard("Item j"),
- buildCard("Item k"),
- buildCard("Item l"),
- ],
- );
- }
-}
-
-class AspectRatioVideo extends StatefulWidget {
- AspectRatioVideo(this.controller);
-
- final VideoPlayerController controller;
-
- @override
- AspectRatioVideoState createState() => AspectRatioVideoState();
-}
-
-class AspectRatioVideoState extends State<AspectRatioVideo> {
- VideoPlayerController get controller => widget.controller;
- bool initialized = false;
-
- VoidCallback listener;
-
- @override
- void initState() {
- super.initState();
- listener = () {
- if (!mounted) {
- return;
- }
- if (initialized != controller.value.initialized) {
- initialized = controller.value.initialized;
- setState(() {});
- }
- };
- controller.addListener(listener);
- }
-
- @override
- Widget build(BuildContext context) {
- if (initialized) {
- return Center(
- child: AspectRatio(
- aspectRatio: controller.value.aspectRatio,
- child: VideoPlayPause(controller),
- ),
- );
- } else {
- return Container();
- }
- }
-}
-
-class App extends StatelessWidget {
+class _App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return DefaultTabController(
@@ -380,11 +33,11 @@
key: const ValueKey<String>('push_tab'),
icon: const Icon(Icons.navigation),
onPressed: () {
- Navigator.push<PlayerVideoAndPopPage>(
+ Navigator.push<_PlayerVideoAndPopPage>(
context,
- MaterialPageRoute<PlayerVideoAndPopPage>(
- builder: (BuildContext context) =>
- PlayerVideoAndPopPage()),
+ MaterialPageRoute<_PlayerVideoAndPopPage>(
+ builder: (BuildContext context) => _PlayerVideoAndPopPage(),
+ ),
);
},
)
@@ -403,47 +56,9 @@
),
body: TabBarView(
children: <Widget>[
- SingleChildScrollView(
- child: Column(
- children: <Widget>[
- Container(
- padding: const EdgeInsets.only(top: 20.0),
- ),
- const Text('With remote mp4'),
- Container(
- padding: const EdgeInsets.all(20),
- child: NetworkPlayerLifeCycle(
- 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
- (BuildContext context,
- VideoPlayerController controller) =>
- AspectRatioVideo(controller),
- ),
- ),
- ],
- ),
- ),
- SingleChildScrollView(
- child: Column(
- children: <Widget>[
- Container(
- padding: const EdgeInsets.only(top: 20.0),
- ),
- const Text('With assets mp4'),
- Container(
- padding: const EdgeInsets.all(20),
- child: AssetPlayerLifeCycle(
- 'assets/Butterfly-209.mp4',
- (BuildContext context,
- VideoPlayerController controller) =>
- AspectRatioVideo(controller)),
- ),
- ],
- ),
- ),
- AssetPlayerLifeCycle(
- 'assets/Butterfly-209.mp4',
- (BuildContext context, VideoPlayerController controller) =>
- VideoInListOfCards(controller)),
+ _BumbleBeeRemoteVideo(),
+ _ButterFlyAssetVideo(),
+ _ButterFlyAssetVideoInList(),
],
),
),
@@ -451,20 +66,244 @@
}
}
-void main() {
- runApp(
- MaterialApp(
- home: App(),
- ),
- );
+class _ButterFlyAssetVideoInList extends StatelessWidget {
+ @override
+ Widget build(BuildContext context) {
+ return ListView(
+ children: <Widget>[
+ _ExampleCard(title: "Item a"),
+ _ExampleCard(title: "Item b"),
+ _ExampleCard(title: "Item c"),
+ _ExampleCard(title: "Item d"),
+ _ExampleCard(title: "Item e"),
+ _ExampleCard(title: "Item f"),
+ _ExampleCard(title: "Item g"),
+ Card(
+ child: Column(children: <Widget>[
+ Column(
+ children: <Widget>[
+ const ListTile(
+ leading: Icon(Icons.cake),
+ title: Text("Video video"),
+ ),
+ Stack(
+ alignment: FractionalOffset.bottomRight +
+ const FractionalOffset(-0.1, -0.1),
+ children: <Widget>[
+ _ButterFlyAssetVideo(),
+ Image.asset('assets/flutter-mark-square-64.png'),
+ ]),
+ ],
+ ),
+ ])),
+ _ExampleCard(title: "Item h"),
+ _ExampleCard(title: "Item i"),
+ _ExampleCard(title: "Item j"),
+ _ExampleCard(title: "Item k"),
+ _ExampleCard(title: "Item l"),
+ ],
+ );
+ }
}
-class PlayerVideoAndPopPage extends StatefulWidget {
+/// A filler card to show the video in a list of scrolling contents.
+class _ExampleCard extends StatelessWidget {
+ const _ExampleCard({Key key, this.title}) : super(key: key);
+
+ final String title;
+
+ @override
+ Widget build(BuildContext context) {
+ return Card(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: <Widget>[
+ ListTile(
+ leading: const Icon(Icons.airline_seat_flat_angled),
+ title: Text(title),
+ ),
+ ButtonBar(
+ children: <Widget>[
+ FlatButton(
+ child: const Text('BUY TICKETS'),
+ onPressed: () {
+ /* ... */
+ },
+ ),
+ FlatButton(
+ child: const Text('SELL TICKETS'),
+ onPressed: () {
+ /* ... */
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ButterFlyAssetVideo extends StatefulWidget {
+ @override
+ _ButterFlyAssetVideoState createState() => _ButterFlyAssetVideoState();
+}
+
+class _ButterFlyAssetVideoState extends State<_ButterFlyAssetVideo> {
+ VideoPlayerController _controller;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.asset('assets/Butterfly-209.mp4');
+
+ _controller.addListener(() {
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize().then((_) => setState(() {}));
+ _controller.play();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: <Widget>[
+ Container(
+ padding: const EdgeInsets.only(top: 20.0),
+ ),
+ const Text('With assets mp4'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: <Widget>[
+ VideoPlayer(_controller),
+ _PlayPauseOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _BumbleBeeRemoteVideo extends StatefulWidget {
+ @override
+ _BumbleBeeRemoteVideoState createState() => _BumbleBeeRemoteVideoState();
+}
+
+class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> {
+ VideoPlayerController _controller;
+
+ Future<ClosedCaptionFile> _loadCaptions() async {
+ final String fileContents = await DefaultAssetBundle.of(context)
+ .loadString('assets/bumble_bee_captions.srt');
+ return SubRipCaptionFile(fileContents);
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = VideoPlayerController.network(
+ 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
+ closedCaptionFile: _loadCaptions(),
+ );
+
+ _controller.addListener(() {
+ setState(() {});
+ });
+ _controller.setLooping(true);
+ _controller.initialize();
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return SingleChildScrollView(
+ child: Column(
+ children: <Widget>[
+ Container(padding: const EdgeInsets.only(top: 20.0)),
+ const Text('With remote mp4'),
+ Container(
+ padding: const EdgeInsets.all(20),
+ child: AspectRatio(
+ aspectRatio: _controller.value.aspectRatio,
+ child: Stack(
+ alignment: Alignment.bottomCenter,
+ children: <Widget>[
+ VideoPlayer(_controller),
+ ClosedCaption(text: _controller.value.caption.text),
+ _PlayPauseOverlay(controller: _controller),
+ VideoProgressIndicator(_controller, allowScrubbing: true),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _PlayPauseOverlay extends StatelessWidget {
+ const _PlayPauseOverlay({Key key, this.controller}) : super(key: key);
+
+ final VideoPlayerController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return Stack(
+ children: <Widget>[
+ AnimatedSwitcher(
+ duration: Duration(milliseconds: 50),
+ reverseDuration: Duration(milliseconds: 200),
+ child: controller.value.isPlaying
+ ? SizedBox.shrink()
+ : Container(
+ color: Colors.black26,
+ child: Center(
+ child: Icon(
+ Icons.play_arrow,
+ color: Colors.white,
+ size: 100.0,
+ ),
+ ),
+ ),
+ ),
+ GestureDetector(
+ onTap: () {
+ controller.value.isPlaying ? controller.pause() : controller.play();
+ },
+ ),
+ ],
+ );
+ }
+}
+
+class _PlayerVideoAndPopPage extends StatefulWidget {
@override
_PlayerVideoAndPopPageState createState() => _PlayerVideoAndPopPageState();
}
-class _PlayerVideoAndPopPageState extends State<PlayerVideoAndPopPage> {
+class _PlayerVideoAndPopPageState extends State<_PlayerVideoAndPopPage> {
VideoPlayerController _videoPlayerController;
bool startedPlaying = false;
@@ -504,8 +343,9 @@
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
if (snapshot.data == true) {
return AspectRatio(
- aspectRatio: _videoPlayerController.value.aspectRatio,
- child: VideoPlayer(_videoPlayerController));
+ aspectRatio: _videoPlayerController.value.aspectRatio,
+ child: VideoPlayer(_videoPlayerController),
+ );
} else {
return const Text('waiting for video to load');
}
diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml
index b83e8d1..fed1c0c 100644
--- a/packages/video_player/video_player/example/pubspec.yaml
+++ b/packages/video_player/video_player/example/pubspec.yaml
@@ -20,3 +20,4 @@
assets:
- assets/flutter-mark-square-64.png
- assets/Butterfly-209.mp4
+ - assets/bumble_bee_captions.srt
diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart
index 97b87b2..f2f9289 100644
--- a/packages/video_player/video_player/lib/video_player.dart
+++ b/packages/video_player/video_player/lib/video_player.dart
@@ -14,6 +14,7 @@
export 'package:video_player_platform_interface/video_player_platform_interface.dart'
show DurationRange, DataSourceType, VideoFormat;
+import 'src/closed_caption_file.dart';
export 'src/closed_caption_file.dart';
final VideoPlayerPlatform _videoPlayerPlatform = VideoPlayerPlatform.instance
@@ -30,6 +31,7 @@
@required this.duration,
this.size,
this.position = const Duration(),
+ this.caption = const Caption(),
this.buffered = const <DurationRange>[],
this.isPlaying = false,
this.isLooping = false,
@@ -54,6 +56,12 @@
/// The current playback position.
final Duration position;
+ /// The [Caption] that should be displayed based on the current [position].
+ ///
+ /// This field will never be null. If there is no caption for the current
+ /// [position], this will be an empty [Caption] object.
+ final Caption caption;
+
/// The currently buffered ranges.
final List<DurationRange> buffered;
@@ -87,8 +95,17 @@
bool get hasError => errorDescription != null;
/// Returns [size.width] / [size.height] when size is non-null, or `1.0.` when
- /// it is.
- double get aspectRatio => size != null ? size.width / size.height : 1.0;
+ /// size is null or the aspect ratio would be less than or equal to 0.0.
+ double get aspectRatio {
+ if (size == null) {
+ return 1.0;
+ }
+ final double aspectRatio = size.width / size.height;
+ if (aspectRatio <= 0) {
+ return 1.0;
+ }
+ return aspectRatio;
+ }
/// Returns a new instance that has the same values as this current instance,
/// except for any overrides passed in as arguments to [copyWidth].
@@ -96,6 +113,7 @@
Duration duration,
Size size,
Duration position,
+ Caption caption,
List<DurationRange> buffered,
bool isPlaying,
bool isLooping,
@@ -107,6 +125,7 @@
duration: duration ?? this.duration,
size: size ?? this.size,
position: position ?? this.position,
+ caption: caption ?? this.caption,
buffered: buffered ?? this.buffered,
isPlaying: isPlaying ?? this.isPlaying,
isLooping: isLooping ?? this.isLooping,
@@ -122,6 +141,7 @@
'duration: $duration, '
'size: $size, '
'position: $position, '
+ 'caption: $caption, '
'buffered: [${buffered.join(', ')}], '
'isPlaying: $isPlaying, '
'isLooping: $isLooping, '
@@ -147,7 +167,8 @@
/// The name of the asset is given by the [dataSource] argument and must not be
/// null. The [package] argument must be non-null when the asset comes from a
/// package and null otherwise.
- VideoPlayerController.asset(this.dataSource, {this.package})
+ VideoPlayerController.asset(this.dataSource,
+ {this.package, this.closedCaptionFile})
: dataSourceType = DataSourceType.asset,
formatHint = null,
super(VideoPlayerValue(duration: null));
@@ -159,7 +180,8 @@
/// null.
/// **Android only**: The [formatHint] option allows the caller to override
/// the video format detection code.
- VideoPlayerController.network(this.dataSource, {this.formatHint})
+ VideoPlayerController.network(this.dataSource,
+ {this.formatHint, this.closedCaptionFile})
: dataSourceType = DataSourceType.network,
package = null,
super(VideoPlayerValue(duration: null));
@@ -168,7 +190,7 @@
///
/// This will load the file from the file-URI given by:
/// `'file://${file.path}'`.
- VideoPlayerController.file(File file)
+ VideoPlayerController.file(File file, {this.closedCaptionFile})
: dataSource = 'file://${file.path}',
dataSourceType = DataSourceType.file,
package = null,
@@ -191,6 +213,15 @@
/// Only set for [asset] videos. The package that the asset was loaded from.
final String package;
+
+ /// Optional field to specify a file containing the closed
+ /// captioning.
+ ///
+ /// This future will be awaited and the file will be loaded when
+ /// [initialize()] is called.
+ final Future<ClosedCaptionFile> closedCaptionFile;
+
+ ClosedCaptionFile _closedCaptionFile;
Timer _timer;
bool _isDisposed = false;
Completer<void> _creatingCompleter;
@@ -269,6 +300,13 @@
}
}
+ if (closedCaptionFile != null) {
+ if (_closedCaptionFile == null) {
+ _closedCaptionFile = await closedCaptionFile;
+ }
+ value = value.copyWith(caption: _getCaptionAt(value.position));
+ }
+
void errorListener(Object obj) {
final PlatformException e = obj;
value = VideoPlayerValue.erroneous(e.message);
@@ -346,7 +384,7 @@
if (_isDisposed) {
return;
}
- value = value.copyWith(position: newPosition);
+ _updatePosition(newPosition);
},
);
} else {
@@ -385,7 +423,7 @@
position = const Duration();
}
await _videoPlayerPlatform.seekTo(_textureId, position);
- value = value.copyWith(position: position);
+ _updatePosition(position);
}
/// Sets the audio volume of [this].
@@ -396,6 +434,33 @@
value = value.copyWith(volume: volume.clamp(0.0, 1.0));
await _applyVolume();
}
+
+ /// The closed caption based on the current [position] in the video.
+ ///
+ /// If there are no closed captions at the current [position], this will
+ /// return an empty [Caption].
+ ///
+ /// If no [closedCaptionFile] was specified, this will always return an empty
+ /// [Caption].
+ Caption _getCaptionAt(Duration position) {
+ if (_closedCaptionFile == null) {
+ return Caption();
+ }
+
+ // TODO: This would be more efficient as a binary search.
+ for (final caption in _closedCaptionFile.captions) {
+ if (caption.start <= position && caption.end >= position) {
+ return caption;
+ }
+ }
+
+ return Caption();
+ }
+
+ void _updatePosition(Duration position) {
+ value = value.copyWith(position: position);
+ value = value.copyWith(caption: _getCaptionAt(position));
+ }
}
class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver {
@@ -714,3 +779,69 @@
}
}
}
+
+/// Widget for displaying closed captions on top of a video.
+///
+/// If [text] is null, this widget will not display anything.
+///
+/// If [textStyle] is supplied, it will be used to style the text in the closed
+/// caption.
+///
+/// Note: in order to have closed captions, you need to specify a
+/// [VideoPlayerController.closedCaptionFile].
+///
+/// Usage:
+///
+/// ```dart
+/// Stack(children: <Widget>[
+/// VideoPlayer(_controller),
+/// ClosedCaption(text: _controller.value.caption.text),
+/// ]),
+/// ```
+class ClosedCaption extends StatelessWidget {
+ /// Creates a a new closed caption, designed to be used with
+ /// [VideoPlayerValue.caption].
+ ///
+ /// If [text] is null, nothing will be displayed.
+ const ClosedCaption({Key key, this.text, this.textStyle}) : super(key: key);
+
+ /// The text that will be shown in the closed caption, or null if no caption
+ /// should be shown.
+ final String text;
+
+ /// Specifies how the text in the closed caption should look.
+ ///
+ /// If null, defaults to [DefaultTextStyle.of(context).style] with size 36
+ /// font colored white.
+ final TextStyle textStyle;
+
+ @override
+ Widget build(BuildContext context) {
+ final TextStyle effectiveTextStyle = textStyle ??
+ DefaultTextStyle.of(context).style.copyWith(
+ fontSize: 36.0,
+ color: Colors.white,
+ );
+
+ if (text == null) {
+ return SizedBox.shrink();
+ }
+
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: Padding(
+ padding: EdgeInsets.only(bottom: 24.0),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ color: Color(0xB8000000),
+ borderRadius: BorderRadius.circular(2.0),
+ ),
+ child: Padding(
+ padding: EdgeInsets.symmetric(horizontal: 2.0),
+ child: Text(text, style: effectiveTextStyle),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml
index 33aa863..43a69a5 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.6
+version: 0.10.7
homepage: https://github.com/flutter/plugins/tree/master/packages/video_player/video_player
flutter:
diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart
index ebabacb..20413b5 100644
--- a/packages/video_player/video_player/test/video_player_test.dart
+++ b/packages/video_player/video_player/test/video_player_test.dart
@@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:video_player/video_player.dart';
@@ -47,6 +48,30 @@
@override
VideoFormat get formatHint => null;
+
+ @override
+ Future<ClosedCaptionFile> get closedCaptionFile => _loadClosedCaption();
+}
+
+Future<ClosedCaptionFile> _loadClosedCaption() async =>
+ _FakeClosedCaptionFile();
+
+class _FakeClosedCaptionFile extends ClosedCaptionFile {
+ @override
+ List<Caption> get captions {
+ return <Caption>[
+ Caption(
+ text: 'one',
+ start: Duration(milliseconds: 100),
+ end: Duration(milliseconds: 200),
+ ),
+ Caption(
+ text: 'two',
+ start: Duration(milliseconds: 300),
+ end: Duration(milliseconds: 400),
+ ),
+ ];
+ }
}
void main() {
@@ -84,6 +109,51 @@
findsOneWidget);
});
+ group('ClosedCaption widget', () {
+ testWidgets('uses a default text style', (WidgetTester tester) async {
+ final String text = 'foo';
+ await tester.pumpWidget(MaterialApp(home: ClosedCaption(text: text)));
+
+ final Text textWidget = tester.widget<Text>(find.text(text));
+ expect(textWidget.style.fontSize, 36.0);
+ expect(textWidget.style.color, Colors.white);
+ });
+
+ testWidgets('uses given text and style', (WidgetTester tester) async {
+ final String text = 'foo';
+ final TextStyle textStyle = TextStyle(fontSize: 14.725);
+ await tester.pumpWidget(MaterialApp(
+ home: ClosedCaption(
+ text: text,
+ textStyle: textStyle,
+ ),
+ ));
+ expect(find.text(text), findsOneWidget);
+
+ final Text textWidget = tester.widget<Text>(find.text(text));
+ expect(textWidget.style.fontSize, textStyle.fontSize);
+ });
+
+ testWidgets('handles null text', (WidgetTester tester) async {
+ await tester.pumpWidget(MaterialApp(home: ClosedCaption(text: null)));
+ expect(find.byType(Text), findsNothing);
+ });
+
+ testWidgets('Passes text contrast ratio guidelines',
+ (WidgetTester tester) async {
+ final String text = 'foo';
+ await tester.pumpWidget(MaterialApp(
+ home: Scaffold(
+ backgroundColor: Colors.white,
+ body: ClosedCaption(text: text),
+ ),
+ ));
+ expect(find.text(text), findsOneWidget);
+
+ await expectLater(tester, meetsGuideline(textContrastGuideline));
+ }, skip: isBrowser);
+ });
+
group('VideoPlayerController', () {
FakeVideoPlayerPlatform fakeVideoPlayerPlatform;
@@ -270,6 +340,34 @@
});
});
+ group('caption', () {
+ test('works when seeking', () async {
+ final VideoPlayerController controller = VideoPlayerController.network(
+ 'https://127.0.0.1',
+ closedCaptionFile: _loadClosedCaption(),
+ );
+
+ await controller.initialize();
+ expect(controller.value.position, const Duration());
+ expect(controller.value.caption.text, isNull);
+
+ await controller.seekTo(const Duration(milliseconds: 100));
+ expect(controller.value.caption.text, 'one');
+
+ await controller.seekTo(const Duration(milliseconds: 250));
+ expect(controller.value.caption.text, isNull);
+
+ await controller.seekTo(const Duration(milliseconds: 300));
+ expect(controller.value.caption.text, 'two');
+
+ await controller.seekTo(const Duration(milliseconds: 500));
+ expect(controller.value.caption.text, isNull);
+
+ await controller.seekTo(const Duration(milliseconds: 300));
+ expect(controller.value.caption.text, 'two');
+ });
+ });
+
group('Platform callbacks', () {
testWidgets('playing completed', (WidgetTester tester) async {
final VideoPlayerController controller = VideoPlayerController.network(
@@ -359,6 +457,7 @@
expect(uninitialized.duration, isNull);
expect(uninitialized.position, equals(const Duration(seconds: 0)));
+ expect(uninitialized.caption, equals(const Caption()));
expect(uninitialized.buffered, isEmpty);
expect(uninitialized.isPlaying, isFalse);
expect(uninitialized.isLooping, isFalse);
@@ -378,6 +477,7 @@
expect(error.duration, isNull);
expect(error.position, equals(const Duration(seconds: 0)));
+ expect(error.caption, equals(const Caption()));
expect(error.buffered, isEmpty);
expect(error.isPlaying, isFalse);
expect(error.isLooping, isFalse);
@@ -395,6 +495,7 @@
const Duration duration = Duration(seconds: 5);
const Size size = Size(400, 300);
const Duration position = Duration(seconds: 1);
+ const Caption caption = Caption(text: 'foo');
final List<DurationRange> buffered = <DurationRange>[
DurationRange(const Duration(seconds: 0), const Duration(seconds: 4))
];
@@ -407,6 +508,7 @@
duration: duration,
size: size,
position: position,
+ caption: caption,
buffered: buffered,
isPlaying: isPlaying,
isLooping: isLooping,
@@ -414,7 +516,7 @@
volume: volume);
expect(value.toString(),
- 'VideoPlayerValue(duration: 0:00:05.000000, size: Size(400.0, 300.0), position: 0:00:01.000000, buffered: [DurationRange(start: 0:00:00.000000, end: 0:00:04.000000)], isPlaying: true, isLooping: true, isBuffering: truevolume: 0.5, errorDescription: null)');
+ 'VideoPlayerValue(duration: 0:00:05.000000, size: Size(400.0, 300.0), position: 0:00:01.000000, caption: Instance of \'Caption\', buffered: [DurationRange(start: 0:00:00.000000, end: 0:00:04.000000)], isPlaying: true, isLooping: true, isBuffering: truevolume: 0.5, errorDescription: null)');
});
test('copyWith()', () {