😸

【完全無料】Flutterを使って簡易ライブ配信アプリを構築する

2021/03/14に公開1

はじめに

皆さんはFlutterというフレームワークを知っていますか?
flutterはgoogleが開発したios,android,webアプリが作れてしまうフレームワークになります。(Dartという言語を使って3つ一気にアプリが作れる!!!)
こちらの記事はそんなflutterのフレームワークを使って開発のアプリの紹介記事になります。

作ろうとおもった経緯

flutterの勉強がてら、なにか簡単アプリを作りたいなと思っていたのですが、自分自身が動画関連の会社に勤めているもあり、せっかくなら、
「ライブ配信が行えるflutterアプリを作ってみよう!」
と思い、今回のアプリを開発しました。

アプリの概要

スマホのカメラで撮影した映像をweb上で視聴できるというアプリケーションになります。
具体的な構成は以下の通りです。

今回作成したflutterアプリケーションでスマホのカメラ映像をrtmpサーバーへ送ります。
rtmpサーバーは送られてきた情報(動画)を受け取ってhls配信を行う形に変換を行います。
hls配信に変換されたファイルはrtmpサーバー内に溜まっていくので、m3u8プレイヤーを使用して、rtmpサーバー内に置かれているhls配信用ファイル(m3u8ファイル)を参照することでスマホから撮影している映像を視聴できるようになります。

  • rtmp(Real Time Messaging Protocol) ・・・・adobeが開発した音声・動画・データをやりとりするストリーミングのプロトコル。
  • hls(Http Live Streaming)・・・Appleが開発したWeb上で動画や音声のストリーミング配信・再生を行なうための規格
    • プレイリストファイル(m3u8ファイル)とセグメントファイル(tsファイル)(動画を数秒に分割したファイル)で構成されている。
  • m3u8 ・・・ プレイリストファイルのこと。このファイルにどのtsファイルを再生するか(tsファイルパス)が書かれている。

ローカル上に配信アプリをつくってみる

実際に無料で配信アプリを作成するためにローカル上でこれらの構成をつくってみようと思います。
以下の手順で作っていきます。

  1. rtmpローカルサーバーの作成
  2. flutterアプリの作成
  3. 動作確認

1. rtmpローカルサーバーの作成

rtmpローカルサーバーを作成するためにdockerでnginxコンテナを構築し、rtmpモジュールとしてnginx-rtmp-moduleを使用します。
以下のディレクトリ構成でファイルを作成し、内容を記述してください。

ディレクトリ構成

ディレクトリ構成
(好きなディレクトリ)/
 ├ nginx.conf
 ├ Dockerfile
 ├ docker-compose.yml

ファイル作成

必要ファイルの作成
$ touch nginx.conf Dockerfile docker-compose.yml

ファイルの中身

nginx.conf(nginxの設定ファイル)の中身はこちらをクリック
nginx.conf
worker_processes auto; # autoに指定するとコア数を設定してくれる。
rtmp_auto_push on;
events {}
rtmp {
    server {
        listen 1935;
        listen [::]:1935 ipv6only=on;

        application live {
            live on;
            record off;

            # HLS
            hls on;
            hls_path /var/local/hls;
            hls_fragment 10s;
        }
    }
}

http { # httpモジュールの設定
    server { # serverコンテキスト
        listen 80; #サーバがどのポートで待ち受けるか

        include mime.types;
        default_type  application/octet-stream;
        server_name localhost; # サーバの名前
        add_header  Access-Control-Allow-Origin *;

        location /hls { # localtionコンテキスト
            types {
                 application/vnd.apple.mpegurl m3u8;
            }
            root /var/local/;
        }
    }
    include /etc/nginx/conf.d/default.conf;
}

Dockerfileの中身はここをクリック
Dockerfile
FROM tiangolo/nginx-rtmp

COPY nginx.conf /etc/nginx/nginx.conf

docker-compose.ymlファイルの中身はここをクリック
docker-compose.yml
version: "3"
services:
  nginx-rtmp:
    build:
      context: .
    ports:
      - 1935:1935
      - 8088:80

rtmpサーバーの起動

サーバー起動
$ docker-compose up -d

起動確認

2. flutterアプリの作成

スマホのカメラ映像をrtmpサーバーへpushするためのアプリを作成します。
カメラ映像をrtmpサーバーへpushするためにflutterのcamera_with_rtmpパッケージを利用します。

パッケージのインストール

pubspec.yamlに今回追加するパッケージを追記します。

pubspec.yamlに追記
dependencies:
  camera_with_rtmp: ^0.3.2
camera_with_rtmpライブラリをインストール
$ flutter pub get

iosを利用する場合

ios端末でカメラの利用を許可するために
info.plistに以下の術記述を追加してください。

ios/Runner/Info.plist
<key>NSCameraUsageDescription</key>
<string>Can I use the camera please?</string>
<key>NSMicrophoneUsageDescription</key>
<string>Can I use the mic please?</string>

androidを利用する場合

android sdkのバージョンを21以上にしてください。

android/app/build.gradle
minSdkVersion 21

また、ファイルを除外するためにパッケージオプションにセクションを追加してください。

packagingOptions {
   exclude 'project.clj'
}

ファイルの中身

スマホ映像をrtmpサーバーへpushするためのコードファイルになります。
適宜、rtmpUrlを変更してお使いください!

live_demo.dartファイルの中身はこちらをクリック

live_demo.dart
import 'dart:async';
import 'package:camera_with_rtmp/camera.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock/wakelock.dart';

class CameraExampleHome extends StatefulWidget {
  
  _CameraExampleHomeState createState() {
    return _CameraExampleHomeState();
  }
}

/// カメラの向きに対応したアイコンデータを返す関数
IconData getCameraLensIcon(CameraLensDirection direction) {
  switch (direction) {
    case CameraLensDirection.back:
      return Icons.camera_rear;
    case CameraLensDirection.front:
      return Icons.camera_front;
    case CameraLensDirection.external:
      return Icons.camera;
  }
  throw ArgumentError('Unknown lens direction');
}

void logError(String code, String message) =>
    print('Error: $code\nError Message: $message');

class _CameraExampleHomeState extends State<CameraExampleHome> with WidgetsBindingObserver {
  CameraController controller;
  String url;
  VideoPlayerController videoController;
  bool enableAudio = true;
  bool useOpenGL = true;
  Timer _timer;

  
  void initState() {
    //WidgetsBindingObserverの初期化
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  
  void dispose() {
    //WidgetsBindingObserverの破棄
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  
  // didChangeAppLifecycleStateを使用してアプリのライフサイクルがどの状態であるかを検出
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (controller == null || !controller.value.isInitialized) {
      return;
    }
    //アプリは表示されているが、フォーカスがあたっていない状態
    if (state == AppLifecycleState.inactive) {
      controller?.dispose();//このカメラのリソースを解放.
      if (_timer != null) {
        _timer.cancel();
        _timer = null;
      }
      //アプリがフォアグランドに遷移し(paused状態から復帰)、復帰処理用の状態
    } else if (state == AppLifecycleState.resumed) {
      //アプリが復帰時(resumed)に処理を実行
      if (controller != null) {
        onNewCameraSelected(controller.description);
      }
    }
  }

  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();

  
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        key: _scaffoldKey,
        appBar: AppBar(
          title: const Text('live demo app'),
        ),
        body: Column(
          children: <Widget>[
            Expanded(
              child: Container(
                child: Padding(
                  padding: const EdgeInsets.all(1.0),
                  child: Center(child: _cameraPreviewWidget()),
                ),
              ),
            ),
            _streamingButtonWidget(),
            _isStreamingRowWidget(),
            Padding(
              padding: const EdgeInsets.all(5.0),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  _cameraTogglesRowWidget(),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  /// カメラからのプレビューを表示するためのwidget(プレビューがない場合はメッセージ)。
  Widget _cameraPreviewWidget() {
    if (controller == null || !controller.value.isInitialized) {
      // カメラの準備ができるまではテキストを表示
      return const Text(
        '下のカメラボタンをタップ',
        style: TextStyle(
          color: Colors.black,
          fontSize: 24.0,
          fontWeight: FontWeight.w900,
        ),
      );
    } else {
      return AspectRatio(
        aspectRatio: controller.value.aspectRatio,
        child: CameraPreview(controller),
      );
    }
  }

  /// 視聴開始するためのボタンと終了ボタンを表示するためのwidget
  Widget _streamingButtonWidget() {
    return (controller != null && controller.value.isInitialized && !controller.value.isStreamingVideoRtmp) ?
    RaisedButton.icon(
      icon:const Icon(Icons.watch),
      label: Text('配信開始'),
      textColor: Colors.blue,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(10),
      ),
      onPressed: () {
        if(controller != null && controller.value.isInitialized && !controller.value.isStreamingVideoRtmp) {
          return onVideoStreamingButtonPressed();
        }
        return null;
      },
    ):
    RaisedButton.icon(
        icon: const Icon(Icons.stop),
        textColor: Colors.red,
        label: Text('配信終了'),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(10),
        ),
        onPressed: () {
          if (controller != null && controller.value.isInitialized && controller.value.isStreamingVideoRtmp) {
            return onStopButtonPressed();
          }
          return null;
        }
    );
  }
  //配信しているかどうかをテキストで表示するためのwidget
  Widget _isStreamingRowWidget() {
    return (controller != null && controller.value.isInitialized && controller.value.isStreamingVideoRtmp)?
    Row(mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: CircularProgressIndicator(),
        ),
        Text("配信中", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
      ],
    ):
    //配信中でない場合にテキストを表示
    Text("配信していません。", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20));
  }
  // カメラのインアウトを切り替えるトグルWidget
  Widget _cameraTogglesRowWidget() {
    final List<Widget> toggles = <Widget>[];

    if (cameras.isEmpty) {
      return const Text('No camera found');
    } else {
      for (CameraDescription cameraDescription in cameras) {
        print(cameraDescription);
        toggles.add(
          SizedBox(
            width: 90.0,
            child: RadioListTile<CameraDescription>(
              title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
              groupValue: controller?.description,
              value: cameraDescription,
              onChanged: onNewCameraSelected,
            ),
          ),
        );
      }
    }
    return Row(children: toggles);
  }

  //snackbarを表示するための関数
  void showInSnackBar(String message) {
    _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(message)));
  }

  // トグルが選択された時に呼ばれるコールバック関数
  void onNewCameraSelected(CameraDescription cameraDescription) async {
    if (controller != null) {
      await controller.dispose();
    }
    controller = CameraController(
      cameraDescription,
      ResolutionPreset.medium,
      enableAudio: enableAudio,
      androidUseOpenGL: useOpenGL,
    );
    // トグルにおうじて、カメラのUIを更新
    controller.addListener(() {
      if (mounted) setState(() {});
      if (controller.value.hasError) {
        showInSnackBar('Camera error ${controller.value.errorDescription}');
        if (_timer != null) {
          _timer.cancel();
          _timer = null;
        }
        Wakelock.disable();
      }
    });

    try {
      await controller.initialize();
    } on CameraException catch (e) {
      _showCameraException(e);
    }

    if (mounted) {
      setState(() {});
    }
  }

  //配信スタートボタンをおされたときに呼ばれす関数
  void onVideoStreamingButtonPressed() {
    startVideoStreaming().then((String url) {
      if (mounted) setState(() {});
      if (url != null) {
        showInSnackBar('配信を開始します。 $url');
      }
      Wakelock.enable();
    });
  }

  //配信ストップボタンをおされたときに呼ばれす関数
  void onStopButtonPressed() {
    if (this.controller.value.isStreamingVideoRtmp) {
      stopVideoStreaming().then((_) {
        if (mounted) setState(() {});
        showInSnackBar('配信終了しました。: $url');
      });
    }
    Wakelock.disable();
  }

  //配信を開始するための関数
  Future<String> startVideoStreaming() async {
    if (!controller.value.isInitialized) {
      showInSnackBar('Error: select a camera first.');
      return null;
    }
    //配信中であればnullを返す(
    if (controller.value.isStreamingVideoRtmp) {
      return null;
    }

    // rtmpURLを指定を(自由に変更してください)
    String rtmpUrl = 'rtmp://localhost:1935/live/test';
    try {
      if (_timer != null) {
        _timer.cancel();
        _timer = null;
      }
      url = rtmpUrl;
      await controller.startVideoStreaming(url);
      _timer = Timer.periodic(Duration(seconds: 1), (timer) async {
        var stats = await controller.getStreamStatistics();
        print(stats);
      });
    } on CameraException catch (e) {
      _showCameraException(e);
      return null;
    }
    return rtmpUrl;
  }

  //配信を終了するための関数
  Future<void> stopVideoStreaming() async {
    if (!controller.value.isStreamingVideoRtmp) {//配信中でなければnullを返す
      return null;
    }

    try {
      await controller.stopVideoStreaming();
      if (_timer != null) {
        _timer.cancel();
        _timer = null;
      }
    } on CameraException catch (e) {
      _showCameraException(e);
      return null;
    }
  }

  void _showCameraException(CameraException e) {
    logError(e.code, e.description);
    showInSnackBar('Error: ${e.code}\n${e.description}');
  }
}

class CameraApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CameraExampleHome(),
    );
  }
}

List<CameraDescription> cameras = [];

Future<void> main() async {
  // エントリーポイント
  try {
    //Flutter Engineの機能を利用したい場合にコールする
    //Flutter Engineの機能とは、Android, iOSなどの画面の向きの設定やロケールなど
    WidgetsFlutterBinding.ensureInitialized();
    cameras = await availableCameras();
  } on CameraException catch (e) {
    logError(e.code, e.description);
  }
  runApp(CameraApp());
}

3. 動作確認


まずは作成したアプリを起動してみましょう。
アプリが起動したら、画面下部のカメラトグルボタンをクリックしてください、

左が外カメラ、右が内カメラとなります。

カメラプレビューが表示されたら、「配信開始」ボタンをクリックしてください。下の文字が「配信されていません」から「配信中」に変わっていると思います。
ここまでできたら、m3u8プレイヤーを開いて、ちゃんと配信されている確認してみましょう。
urlの入力欄には

を入力してください。

これでスマホのカメラ映像で配信できていることが確認できました。お疲れさまでした。

さいごに

今回はflutterとnginxを利用してローカル上に簡単なライブ配信について紹介しました。
なにかわからない点などございましたら、コメントなどよろしくお願いします。

参考

https://qiita.com/rei_012/items/1dd241146cc6a2b5bd44
https://mimemo.io/m/qERa6lBBYDlPb0v

Discussion

suraimusuraimu
  1. rtmpローカルサーバーの作成のとこについて、もう少し詳しく書いてもらうことはできますか?
    コンテナの構築というのがいまいちよく分かっていなくて…