What we're building
We'll see how to build a cross-platform mobile app that allows users to upload and share videos.
The stack
- Flutter - A new cross-platform mobile app development framework by Google, using the Dart language.
- Firebase - The serverless real time database, for storing and syncing video metadata between clients.
- Publitio - The hosting platform we'll use for storing and delivering the videos.
Why Flutter
Flutter gives us a way of writing one code base for both iOS and Android, without having to duplicate the logic or the UI.
The advantage over solutions like Cordova is that it's optimized for mobile performance, giving native-like response. The advantage over the likes of React Native is that you can write the UI once, as it circumvents the OS native UI components entirely. Flutter also has a first rate development experience, including hot reload and debugging in VSCode, which is awesome.
Why Firebase
Keeping things serverless has huge benefits for the time of development and scalability of the app. We can focus on our app instead of server devops. Firebase let's us sync data between clients without server side code, including offline syncing features (which are quite hard to implement). Flutter also has a set of firebase plugins that make it play nicely with Flutter widgets, so we get realtime syncing of the client UI when another client changes the data.
Why Publitio
We'll use Publitio as our Media Asset Management API. Publitio will be responsible for hosting our files, delivering them to clients using it's CDN, thumbnail/cover image generation, transformations/cropping, and other video processing features.
By using an API for this, we can keep the app serverless (and therefore more easily scalable and maintainable), and not reinvent video processing in-house.
Let's start
First, make sure you have a working flutter environment set up, using Flutter's Getting Started.
Now let's create a new project named flutter_video_sharing
. In terminal run:
flutter create --org com.learningsomethingnew.fluttervideo --project-name flutter_video_sharing -a kotlin -i swift flutter_video_sharing
To check the project has created successfully, run flutter doctor
, and see if there are any issues.
Now run the basic project using flutter run
for a little sanity check (you can use an emulator or a real device, Android or iOS).
This is a good time to init a git repository and make the first commit.
Taking a video
In order to take a video using the device camera, we'll use the Image Picker plugin for Flutter.
In pubspec.yaml
dependencies section, add the line (change to latest version of the plugin):
image_picker: ^0.6.1+10
You might notice that there is also a Camera Plugin. I found this plugin to be quite unstable for now, and has serious video quality limitations on many android devices, as it supports only camera2 API.
iOS configuration
For iOS, we'll have to add a camera and mic usage description in ios/Runner/Info.plist
:
<key>NSMicrophoneUsageDescription</key>
<string>Need access to mic</string>
<key>NSCameraUsageDescription</key>
<string>Need access to camera</string>
In order to test the camera on iOS you'll have to use a real device as the simulator doesn't have a camera
Using the plugin
In lib/main.dart
edit _MyHomePageState
class with the following code:
class _MyHomePageState extends State<MyHomePage> {
List<String> _videos = <String>[];
bool _imagePickerActive = false;
void _takeVideo() async {
if (_imagePickerActive) return;
_imagePickerActive = true;
final File videoFile =
await ImagePicker.pickVideo(source: ImageSource.camera);
_imagePickerActive = false;
if (videoFile == null) return;
setState(() {
_videos.add(videoFile.path);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _videos.length,
itemBuilder: (BuildContext context, int index) {
return Card(
child: Container(
padding: const EdgeInsets.all(8),
child: Center(child: Text(_videos[index])),
),
);
})),
floatingActionButton: FloatingActionButton(
onPressed: _takeVideo,
tooltip: 'Take Video',
child: Icon(Icons.add),
),
);
}
}
What's going on here:
- We're replacing the
_incrementCounter
method with_takeVideo
, and also making itasync
. This is what happens when we click the Floating Action Button. - We're taking a video with
await ImagePicker.pickVideo(source: ImageSource.camera);
. - We're saving the video file paths in the
_videos
list in our widget's state. We callsetState
so that the widget will rebuild and the changes reflect in the UI. - In order to get some visual feedback, we replace the scaffold's main
Column
with aListView
usingListView.builder
which will render our dynamic list efficiently.
Running at this stage will look like this:
Add Publitio
Create a free account at Publit.io, and get your credentials from the dashboard.
Note: The link above has my referral code. If my writing is valuable to you, you can support it by using this link. Of course, you can just create an account without me ðŸ˜
Add the package
Add the flutter_publitio plugin to your pubspec.yaml
dependencies section:
dependencies:
flutter_publitio: ^1.0.0
Create an env file
A good way to store app configuration is by using .env
files, and loading them with flutter_dotenv, which in turn implements a guideline from The Twelve-Factor App. This file will contain our API key and secret, and therefore should not be committed to source-control.
So create an .env
file in the project's root dir, and put your credentials in it:
PUBLITIO_KEY=12345abcd
PUBLITIO_SECRET=abc123
Now add flutter_dotenv
as a dependency, and also add the .env
file as an asset in pubspec.yaml
:
dependencies:
flutter_dotenv: ^2.0.3
...
flutter:
assets:
- .env
iOS configuration
For iOS, the keys are loaded from Info.plist
. In order to keep our configuration in our environment and not commit it into our repository, we'll load the keys from an xcconfig
file:
- In XCode, open
ios/Runner.xworkspace
(this is the XCode project Flutter has generated), right click on Runner -> New File -> Other -> Configuration Settings File -> Config.xcconfig - In
Config.xcconfig
, add the keys like you did in the.env
file:
PUBLITIO_KEY = 12345abcd
PUBLITIO_SECRET = abc123
Now we'll import this config file from ios/Flutter/Debug.xcconfig
and ios/Flutter/Release.xcconfig
by adding this line to the bottom of both of them:
#include "../Runner/Config.xcconfig"
Add Config.xcconfig to .gitignore so you don't have your keys in git
The last step is to add the config keys to ios/Runner/Info.plist
:
<key>PublitioAPIKey</key>
<string>$(PUBLITIO_KEY)</string>
<key>PublitioAPISecret</key>
<string>$(PUBLITIO_SECRET)</string>
Android configuration
In Android, all we have to do is change minSdkVersion
from 16 to 19 in android/app/build.gradle
.
Is it just me or is everything always simpler with Android?
Upload the file
Now that we have publitio added, let's upload the file we got from ImagePicker.
In main.dart
, in the _MyHomePageState
class, we'll add a field keeping the status of the upload:
bool _uploading = false;
Now we'll override initState
and call an async function configurePublitio
that will load the API keys:
@override
void initState() {
configurePublitio();
super.initState();
}
static configurePublitio() async {
await DotEnv().load('.env');
await FlutterPublitio.configure(
DotEnv().env['PUBLITIO_KEY'], DotEnv().env['PUBLITIO_SECRET']);
}
We'll add a function _uploadVideo
that calls publitio API's uploadFile
:
static _uploadVideo(videoFile) async {
print('starting upload');
final uploadOptions = {
"privacy": "1",
"option_download": "1",
"option_transform": "1"
};
final response =
await FlutterPublitio.uploadFile(videoFile.path, uploadOptions);
return response;
}
We'll add the calling code to our _takeVideo
function:
setState(() {
_uploading = true;
});
try {
final response = await _uploadVideo(videoFile);
setState(() {
_videos.add(response["url_preview"]);
});
} on PlatformException catch (e) {
print('${e.code}: ${e.message}');
//result = 'Platform Exception: ${e.code} ${e.details}';
} finally {
setState(() {
_uploading = false;
});
}
Notice that the response from publitio API will come as a key-value map, from which we're only saving the url_preview
, which is the url for viewing the hosted video. We're saving that to our _videos
collection, and returning _uploading
to false after the upload is done.
And finally we'll change the floating action button to a spinner whenever _uploading
is true:
floatingActionButton: FloatingActionButton(
child: _uploading
? CircularProgressIndicator(
valueColor: new AlwaysStoppedAnimation<Color>(Colors.white),
)
: Icon(Icons.add),
onPressed: _takeVideo),
Add thumbnails
One of the things publitio makes easy is server-side video thumbnail extraction. You can use it's URL transformation features to get a thumbnail of any size, but for this we'll use the default thumbnail that is received in the upload response.
Now that we want every list item to have a url and a thumbnail, it makes sense to extract a simple POCO class for each video entry. Create a new file lib/video_info.dart
:
class VideoInfo {
String videoUrl;
String thumbUrl;
VideoInfo({this.videoUrl, this.thumbUrl});
}
We'll change the _videos
collection from String
to VideoInfo
:
List<VideoInfo> _videos = <VideoInfo>[];
And after getting the upload response, we'll add a VideoInfo object with the url and the thumbnail url:
final response = await _uploadVideo(videoFile);
setState(() {
_videos.add(VideoInfo(
videoUrl: response["url_preview"],
thumbUrl: response["url_thumbnail"]));
});
Finally we'll add the thumbnail display to the list builder item:
child: new Container(
padding: new EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Stack(
alignment: Alignment.center,
children: <Widget>[
Center(child: CircularProgressIndicator()),
Center(
child: ClipRRect(
borderRadius: new BorderRadius.circular(8.0),
child: FadeInImage.memoryNetwork(
placeholder: kTransparentImage,
image: _videos[index].thumbUrl,
),
),
),
],
),
Padding(padding: EdgeInsets.only(top: 20.0)),
ListTile(
title: Text(_videos[index].videoUrl),
),
],
),
A few things here:
- We're giving the thumbnail nice round borders using
ClipRRect
- We're displaying a
CircularProgressIndicator
that displays while the thumbnail is loading (it will be hidden by the image after loading) - For a nice fade effect, we're using
kTransparentImage
from the packagetransparent_image
(which needs to be added topubspec.yaml
)
And now we have nice thumbnails in the list:
Play the video
Now that we have the list of videos, we want to play each video when tapping on the list card.
We'll use the Chewie plugin as our player. Chewie wraps the video_player plugin with native looking UI for playing, skipping, and full screening.
It also supports auto rotating the video according to device orientation.
What it can't do (odly), is figure out the aspect ratio of the video automatically. So we'll get that from the publitio result.
Note: Flutter's video_player package doesn't yet support caching, so replaying the video will cause it to re-download. This should be solved soon: https://github.com/flutter/flutter/issues/28094
So add to pubspec.yaml
:
video_player: ^0.10.2+5
chewie: ^0.9.8+1
For iOS, we'll also need to add to the following to Info.plist
to allow loading remote videos:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Now we'll add a new widget that will hold chewie. Create a new file chewie_player.dart
:
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'video_info.dart';
class ChewiePlayer extends StatefulWidget {
final VideoInfo video;
const ChewiePlayer({Key key, @required this.video}) : super(key: key);
@override
State<StatefulWidget> createState() => _ChewiePlayerState();
}
class _ChewiePlayerState extends State<ChewiePlayer> {
ChewieController chewieCtrl;
VideoPlayerController videoPlayerCtrl;
@override
void initState() {
super.initState();
videoPlayerCtrl = VideoPlayerController.network(widget.video.videoUrl);
chewieCtrl = ChewieController(
videoPlayerController: videoPlayerCtrl,
autoPlay: true,
autoInitialize: true,
aspectRatio: widget.video.aspectRatio,
placeholder: Center(
child: Image.network(widget.video.coverUrl),
),
);
}
@override
void dispose() {
if (chewieCtrl != null) chewieCtrl.dispose();
if (videoPlayerCtrl != null) videoPlayerCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: <Widget>[
Chewie(
controller: chewieCtrl,
),
Container(
padding: EdgeInsets.all(30.0),
child: IconButton(
icon: Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
),
],
),
);
}
}
A few things to note:
-
ChewiePlayer
expects to getVideoInfo
which is the video to be played. - The aspect ratio is initialized from the input
VideoInfo
. We'll add this field soon. - We show a placeholder image while the video is loading. We'll use publitio API to generate the cover image.
- We have an
IconButton
that will close this widget by callingNavigator.pop(context)
- We have to take care of disposing both the
VideoPlayerController
and theChewieController
by overriding thedispose()
method
In video_info.dart
we'll add the aspectRatio
and coverUrl
fields:
String coverUrl;
double aspectRatio;
And now in main.dart
, we'll first import our new chewie_player
:
import 'chewie_player.dart';
Add a calculation of aspectRatio
:
final response = await _uploadVideo(videoFile);
final width = response["width"];
final height = response["height"];
final double aspectRatio = width / height;
setState(() {
_videos.add(VideoInfo(
videoUrl: response["url_preview"],
thumbUrl: response["url_thumbnail"]));
thumbUrl: response["url_thumbnail"],
aspectRatio: aspectRatio,
coverUrl: getCoverUrl(response),
));
});
Add a method to get the cover image from publitio API (this is just replacing the extension of the video to jpg
- publitio does all the work):
static const PUBLITIO_PREFIX = "https://media.publit.io/file";
static getCoverUrl(response) {
final publicId = response["public_id"];
return "$PUBLITIO_PREFIX/$publicId.jpg";
}
And wrap our list item Card
with a GestureDetector
to respond to tapping on the card, and call Navigator.push
that will route to our new ChewiePlayer
widget:
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return ChewiePlayer(
video: _videos[index],
);
},
),
);
},
child: Card(
child: new Container(
padding: new EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Stack(
alignment: Alignment.center,
children: <Widget>[
Center(child: CircularProgressIndicator()),
Center(
child: ClipRRect(
borderRadius: new BorderRadius.circular(8.0),
child: FadeInImage.memoryNetwork(
placeholder: kTransparentImage,
image: _videos[index].thumbUrl,
),
),
),
],
),
Padding(padding: EdgeInsets.only(top: 20.0)),
ListTile(
title: Text(_videos[index].videoUrl),
),
],
),
),
),
);
Add Firebase
Now that we can upload and playback videos, we want users of the app to view the videos other users posted. To do that (and keep the app serverless) we'll use Firebase.
Firebase Setup
First, setup Firebase as described here. This includes creating a firebase project, registering your mobile apps (Android and iOS) in the project, and configuring the credentials for both the Android and iOS projects. Then we'll add the Flutter packages firebase_core and cloud_firestore.
Cloud Firestore is the new version of the Firebase Realtime Database.
You'll probably need to setmultiDexEnabled true
in yourbuild.gradle
.
Save video info to Firebase
Instead of saving the video info to our own state, we'll save it to a new Firebase document in the videos
collection:
final video = VideoInfo(
videoUrl: response["url_preview"],
thumbUrl: response["url_thumbnail"],
coverUrl: getCoverUrl(response),
aspectRatio: getAspectRatio(response),
);
await Firestore.instance.collection('videos').document().setData({
"videoUrl": video.videoUrl,
"thumbUrl": video.thumbUrl,
"coverUrl": video.coverUrl,
"aspectRatio": video.aspectRatio,
});
The document()
method will create a new randomly named document inside the videos
collection.
This is how the documents look in the Firebase Console:
Show video list from Firebase
Now in our initState
method we'll want to start a Firebase query that will listen to the videos
collection. Whenever the Firebase SDK triggers a change in the data, it will invoke the updateVideos
method, which will update our _videos
state (and Flutter will rebuild the UI):
@override
void initState() {
configurePublitio();
listenToVideos();
super.initState();
}
listenToVideos() async {
Firestore.instance.collection('videos').snapshots().listen(updateVideos);
}
void updateVideos(QuerySnapshot documentList) async {
final newVideos = mapQueryToVideoInfo(documentList);
setState(() {
_videos = newVideos;
});
}
static mapQueryToVideoInfo(QuerySnapshot documentList) {
return documentList.documents.map((DocumentSnapshot ds) {
return VideoInfo(
videoUrl: ds.data["videoUrl"],
thumbUrl: ds.data["thumbUrl"],
coverUrl: ds.data["coverUrl"],
aspectRatio: ds.data["aspectRatio"],
);
}).toList();
}
Now all the videos are shared!
Refactoring
Now that everything's working, it's a good time for some refactoring. This is a really small app, but still some obvious things stand out.
We have our data access and business logic all sitting in the same place - never a good idea. It's better to have our API/data access in separate modules, providing the business logic layer with the required services, while it can stay agnostic to (and Loosely Coupled from) the implementation details.
In order to keep this post short(ish) I won't include these changes here, but you can see them in the final code on GitHub
Future improvement: client side encoding
We can use FFMpeg to encode the video on the client before uploading.
This will save storage and make delivery faster, but will require a patient uploading user.
If you want to see how to do that, write a comment below.
Thanks for reading!
Full code can be found on GitHub.
If you have any questions, please leave a comment!
Top comments (1)
Hi, I really loved your post, I learned a lot from it. If it means anything, I'm one of those who would really love to see improvement with ffmpeg, that is cross-platform compatible.
Thanks!