Beautifying the Flutter Video Player

image_pdfimage_print

A Stacked Implementation

In my last article I wrote about using the video_player package to show video thumbnails and play videos from a URL. The implementation was quite straightforward and involved wrapping the VideoPlayer widget with a GestureDetector. This also meant manually checking whether or not the video was playing when it was tapped so that we would know to play or pause it:

onTap: () => model.videoPlayerController.value.isPlaying
    ? model.pauseVideo()
    : model.playVideo(),

Since writing that article, I’ve discovered a much cleaner way to do the same thing…so that’s what I’m writing about today!

To summarize, the video_player package includes a few built in video overlay widgets that will handle playing, pausing, scrubbing, and displaying captions on top of your video. The example from the GitHub repo also includes code to change the playback speed of the video. The end result, once all improvements are made, will look like this:

I’ll be building off of the code from the last article so if you haven’t read it, here’s the link →

Displaying Videos from a Firebase Cloud Storage URL
With the Stacked architecturemedium.com

Setup

Stacked

Stacked is an MVVM state management solution built on top of the provider package. It allows you to separate your UI and business logic into Views and ViewModels, respectively and it’s currently my architecture of choice.

stacked: ^1.7.7

Video Player

The video_player package is an official flutter.dev package and it contains the essential widgets we’ll be using in this tutorial.

video_player: ^1.0.1

The View

Video Thumbnail

In the last article, we made a StackedVideoView that would dynamically fit it’s container based on the value of a ‘showFull’ boolean. For the sake of modularity, I’ll be extracting the contents of that widget into a ViewModelWidget called VideoThumbnail. It will continue to use the same ViewModel as before since we want one shared class controlling the video and it’s overlays. Inside the videoViewStacked folder, I’ll add a new videoViewWidgets folder like this:

View — ViewModel — ViewModelWidgets

Inside this new video_thumbnail.dart file, we can add the ViewModelWidget like so:

class VideoThumbnail extends ViewModelWidget<StackedVideoViewModel> {
@override
Widget build(BuildContext context, StackedVideoViewModel model) {
return Builder(
builder: (context) {
// If we want to show the full video, we need to scale it to fit the longest side
if (model.showFull) {
bool wideVideo =
model.videoPlayerController.value.size.width >
model.videoPlayerController.value.size.height;
if (wideVideo) {
return Row(
children: [
Expanded(
child: AspectRatio(
aspectRatio:
model.videoPlayerController.value.aspectRatio,
child: VideoPlayer(model.videoPlayerController),
))
],
);
} else {
return Column(
children: [
Expanded(
child: AspectRatio(
aspectRatio:
model.videoPlayerController.value.aspectRatio,
child: VideoPlayer(model.videoPlayerController),
))
],
);
}
}
// Else just show a portion of the video viewport
else {
return FittedBox(
fit: BoxFit.cover,
alignment: Alignment(model.x, model.y),
child: SizedBox(
height:
model.videoPlayerController.value.size?.height ?? 0,
width: model.videoPlayerController.value.size?.width ?? 0,
child: VideoPlayer(model.videoPlayerController),
),
);
}
},
);
}
}
view raw video_thumbnail.dart hosted with ❤ by GitHub

With this extracted, we can modify the root StackedVideoView to determine when it shows just the video thumbnail and when it adds the video control overlays. What overlays are available?

Video Overlays

Full disclaimer: I initially found this code in the video_player example repo before refactoring it to work with Stacked

To make our video player actually presentable, we need to add a bunch of things. First, it would be nice to make the play/pause functionality obvious with an icon. Second, we should let the user scrub the video forward or backward to start their viewing experience wherever they like. And finally, we should have a theme-matching progress bar that shows how much time is left in the video. We’re not asking for much.

The video_player plugin comes built with these classes:

  • VideoProgressIndicator — “Displays the play/buffering status of the video controlled by [controller]”
  • VideoProgressColors — “Used to configure the [VideoProgressIndicator] widget’s colors for how it describes the video’s status”
  • ClosedCaption — “Widget for displaying closed captions on top of a video”

The example repo also provides a _ControlsOverlay class that’s used primarily to show and hide the video’s play button. My refactor of this is below:

class VideoControlOverlay extends ViewModelWidget<StackedVideoViewModel> {
@override
Widget build(BuildContext context, StackedVideoViewModel model) {
return Container(
height: model.thumbnailHeight,
width: model.thumbnailWidth,
child: Stack(
children: <Widget>[
AnimatedSwitcher(
duration: Duration(milliseconds: 50),
reverseDuration: Duration(milliseconds: 200),
child: model.videoPlayerController.value.isPlaying
? SizedBox.shrink()
: Container(
color: Colors.black26,
child: Center(
child: Icon(
Icons.play_arrow,
color: Colors.white,
size: model.thumbnailHeight/4,
),
),
),
),
GestureDetector(
onTap: () {
model.videoPlayerController.value.isPlaying
? model.pauseVideo()
: model.playVideo();
},
),
],
),
);
}
}

With the built in widgets and our new overlay, the last step is to stack it all up:

Stack(
  alignment: Alignment.bottomCenter,
  children: [
    VideoThumbnail(),
    VideoControlOverlay()
    VideoProgressIndicator(
       model.videoPlayerController,allowScrubbing: true,),
  ],
);

The stack order is important here since both the VideoControlOverlay and VideoProgressIndicator can accept gestures.

Customize It

There’s a few things you can change about the video widget’s appearance so that it matches the theme of your app. The easiest one is the VideoProgressIndicator’s color scheme.

Pass an instance of the VideoProgrossColors class to the indicator like this:

VideoProgressIndicator(
  model.videoPlayerController,
  allowScrubbing: true,
  colors: VideoProgressColors(
    backgroundColor: Colors.green,
    bufferedColor: Colors.yellow,
    playedColor: Colors.purple
  ),
),

Adding padding to the VideoProgressIndicator does exactly what you’d expect.

VideoProgressIndicator(
  model.videoPlayerController,
  allowScrubbing: true,
  padding: EdgeInsets.all(8),
  colors: VideoProgressColors(
    backgroundColor: Colors.green,
    bufferedColor: Colors.yellow,
    playedColor: Colors.purple
  ),
),

ViewModel (and Video Thumbnail Sizing)

The ViewModel

Just like in the last article, the ViewModel here is primarily responsible for initializing and managing the VideoPlayerController. Because we’re dynamically determining the size of the video thumbnail and then stacking things on it, we need the ViewModel to do a bit more: keep track of the video size. The full code is here for reference.

class StackedVideoViewModel extends BaseViewModel {
// Input Properties
VideoPlayerController videoPlayerController;
bool showFull;
double x; // X alignment of FittedBox
double y; // Y alignment of FittedBox
// Local Properties
bool gotThumbnailSize = false;
double thumbnailWidth = 300;
double thumbnailHeight = 200;
/// Keys
GlobalKey thumbnailKey = GlobalObjectKey('video_thumbnail');
void initialize(String videoUrl, bool full,double inx, double iny) {
showFull = full;
x = inx;
y = iny;
videoPlayerController = VideoPlayerController.network(videoUrl);
videoPlayerController.initialize().then((value) {
videoPlayerController.setLooping(true);
notifyListeners();
});
}
void playVideo(){
videoPlayerController.play();
notifyListeners();
}
void pauseVideo(){
videoPlayerController.pause();
notifyListeners();
}
void getVideoSize(){
/// (4) Get the RenderBox from the GlobalObjectKey and extract it's size
RenderBox render = thumbnailKey.currentContext.findRenderObject() as RenderBox;
thumbnailWidth = render.size.width;
thumbnailHeight = render.size.height;
gotThumbnailSize = true;
/// (5) Update the Views
notifyListeners();
}
@override
void dispose() {
videoPlayerController.dispose();
super.dispose();
}
}

Video Thumbnail Sizing

If you add the VideoControlOverlay to the stack without giving it an explicit size, it fills the container and looks sloppy (the darker box is the overlay).

Our goal is to size the overlay to match that of the video. In order to calculate the video thumbnail’s size, we need to do a few things:

  1. Make the VideoThumbnail widget use the WidgetsBindingObserver mixin
  2. Mark the final video thumbnail widget with a GlobalObjectKey. There are three potential thumbnail widgets that will be shown so we need to tag them all
  3. Add a PostFrameCallback to the widget to get it’s size
  4. Once the callback is triggered, get the RenderBox from the GlobalObjectKey and extract it’s size
  5. notifyListeners()

Once we have the width and height from the video thumbnail, we can use that to size the VideoControlOverlay. The gist below is commented to show these steps.

/// (1) Use the WidgetsBindingObserver to get the VideoThumbnail's size
/// after it is laid out
class VideoThumbnail extends ViewModelWidget<StackedVideoViewModel> with WidgetsBindingObserver{
@override
Widget build(BuildContext context, StackedVideoViewModel model) {
/// (3) Get the size of the widget after it is rendered on screen
WidgetsBinding.instance
.addPostFrameCallback((_) {
if(!model.gotThumbnailSize)model.getVideoSize();
});
return Builder(
builder: (context) {
// If we want to show the full video, we need to scale it to fit the longest side
if (model.showFull) {
bool wideVideo =
model.videoPlayerController.value.size.width >
model.videoPlayerController.value.size.height;
if (wideVideo) {
return Row(
/// (2) Mark the final video thumbnail with a GlobalObject Key
key: model.thumbnailKey,
children: [
Expanded(
child: AspectRatio(
aspectRatio:
model.videoPlayerController.value.aspectRatio,
child: VideoPlayer(model.videoPlayerController),
))
],
);
} else {
return Column(
/// (2) Mark the final video thumbnail with a GlobalObject Key
key: model.thumbnailKey,
children: [
Expanded(
child: AspectRatio(
aspectRatio:
model.videoPlayerController.value.aspectRatio,
child: VideoPlayer(model.videoPlayerController),
))
],
);
}
}
// Else just show a portion of the video viewport
else {
return FittedBox(
/// (2) Mark the final video thumbnail with a GlobalObject Key
key: model.thumbnailKey,
fit: BoxFit.cover,
alignment: Alignment(model.x, model.y),
child: SizedBox(
height:
model.videoPlayerController.value.size?.height ?? 0,
width: model.videoPlayerController.value.size?.width ?? 0,
child: VideoPlayer(model.videoPlayerController),
),
);
}
},
);
}
}
view raw video_thumbnail.dart hosted with ❤ by GitHub

Steps 4 and 5 take place in the ViewModel (see above). The width and height of the thumbnail can be plugged into the VideoControlOverlay and BAM!

Extras

Time Remaining

To display the video time remaining, add the following getters to your ViewModel:

Duration get totalVideoLength{
  return videoPlayerController.value.duration;
}

String get totalVideoLengthString{
  return _printDuration(totalVideoLength);
}

Duration get timeRemaining {
  Duration current = videoPlayerController.value.position;
  int millis = totalVideoLength.inMilliseconds - current.inMilliseconds;
  return Duration(milliseconds: millis);
}

String get timeRemainingString {
  return _printDuration( timeRemaining);
}

The _printDuration function comes from this Stack Overflow question.

In your ViewModel initialize() function, add a listener to your videoPlayerController that will notify all listeners when the video is playing. We need this piece so that the calculated time remaining gets refreshed regularly.

videoPlayerController.addListener(() {
  if(remaining && videoPlayerController.value.isPlaying) {
    notifyListeners();
  }
});

The VideoTimeRemaining widget looks like this:

class VideoTimeRemaining extends ViewModelWidget<StackedVideoViewModel> {
@override
Widget build(BuildContext context, StackedVideoViewModel model) {
return Container(
padding: EdgeInsets.only(bottom: 8, right: 8),
height: model.thumbnailHeight,
width: model.thumbnailWidth,
child: Align(
alignment: Alignment.bottomRight,
child: Container(
child: Text(
model.timeRemainingString,
style: TextStyle(color: Colors.white),
),
),
),
);
}
}

Notice that we need to wrap the Text widget in a container with the same size as the video thumbnail. If you forget this, it’s possible the time remaining won’t be inside the video frame.

Time Elapsed

How valuable is knowing how much time is left if we don’t know how much time we’ve watched? No valuable.

Add these getters to your view model:

Duration get timeElapsed {
  return videoPlayerController.value.position;
}

String get timeElapsedString {
  return _printDuration(timeElapsed);
}

And create the VideoTimeElapsed widget.

class VideoTimeElapsed extends ViewModelWidget<StackedVideoViewModel> {
@override
Widget build(BuildContext context, StackedVideoViewModel model) {
return Container(
padding: EdgeInsets.only(bottom: 8, left: 8),
height: model.thumbnailHeight,
width: model.thumbnailWidth,
child: Align(
alignment: Alignment.bottomLeft,
child: Container(
child: Text(
model.timeElapsedString,
style: TextStyle(color: Colors.white),
),
),
),
);
}
}

I’ll be updating this post with additional video features as I come across them so check back←


Widget Depot

You can find the full code for this widget and a handful of others in my Widget Depot repo. My goal is to have Stacked and Stateful versions of all widgets so you can use them regardless of your app’s structure. Feel free to open pull requests and report issues, too!

jtmuller5/Widget-Depot
Repository for reusable Flutter widgets This project is a starting point for a Flutter application. A few resources to…github.com

Joseph Muller is developing Flutter apps for 2021
Coding In/Writing About FlutterCurrent Projects:- Stacked architecture tutorials- Att@ched app (@protocol)- Flutter…www.buymeacoffee.com

Leave a Reply