We’ll see how to build a cross-platform mobile app that allows users to upload and share videos.
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.
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.
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.
Security warning: Publitio doesn’t (yet?) support serverless authentication and user management. All of your app’s users will use the same API key and secret, so theoretically every client can gain access to all files (including deleting them). This is a major caveat if you’re building a serverless app. To solve this problem, you should call Publitio from the server side using a Cloud Function like I describe in the next post.
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.
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.
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
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);
});
}
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:
_incrementCounter
method with _takeVideo
, and also making it async
. This is what happens when we click the Floating Action Button.await ImagePicker.pickVideo(source: ImageSource.camera);
._videos
list in our widget’s state. We call setState
so that the widget will rebuild and the changes reflect in the UI.Column
with a ListView
using ListView.builder
which will render our dynamic list efficiently.Running at this stage will look like this:
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 flutter_publitio plugin to your pubspec.yaml
dependencies section:
dependencies:
flutter_publitio: ^1.0.0
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
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:
ios/Runner.xworkspace
(this is the XCode project Flutter has generated), right click on Runner -> New File -> Other -> Configuration Settings File -> Config.xcconfigIn 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>
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?
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:
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),
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:
ClipRRect
CircularProgressIndicator
that displays while the thumbnail is loading (it will be hidden by the image after loading)kTransparentImage
from the package transparent_image
(which needs to be added to pubspec.yaml
)And now we have nice thumbnails in the list:
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, this.video}) : super(key: key);
State<StatefulWidget> createState() => _ChewiePlayerState();
}
class _ChewiePlayerState extends State<ChewiePlayer> {
ChewieController chewieCtrl;
VideoPlayerController videoPlayerCtrl;
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),
),
);
}
void dispose() {
if (chewieCtrl != null) chewieCtrl.dispose();
if (videoPlayerCtrl != null) videoPlayerCtrl.dispose();
super.dispose();
}
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 get VideoInfo
which is the video to be played.VideoInfo
. We’ll add this field soon.IconButton
that will close this widget by calling Navigator.pop(context)
VideoPlayerController
and the ChewieController
by overriding the dispose()
methodIn 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),
),
],
),
),
),
);
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.
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 set
multiDexEnabled true
in yourbuild.gradle
.
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:
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):
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!
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
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!