[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()', () {