DEV Community

Cover image for How to make a cross-platform serverless video sharing app with Flutter, Firebase and Publitio
Jonathan P
Jonathan P

Posted on • Originally published at learningsomethingnew.com

How to make a cross-platform serverless video sharing app with Flutter, Firebase and Publitio

What we're building

We'll see how to build a cross-platform mobile app that allows users to upload and share videos.

final

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 it async. 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 call setState 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 a ListView using ListView.builder which will render our dynamic list efficiently.

Running at this stage will look like this:

image-picker

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 package transparent_image (which needs to be added to pubspec.yaml)

And now we have nice thumbnails in the list:

thumbnail

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 get VideoInfo 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 calling Navigator.pop(context)
  • We have to take care of disposing both the VideoPlayerController and the ChewieController by overriding the dispose() 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 set multiDexEnabled true in your build.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:

firestore

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!

final

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)

Collapse
 
shakal187 profile image
shakal187

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!