FlutterでLiveKitを利用して1対1ビデオチャットアプリを作成する

# はじめに
　私は2023年10月より1か月半、大学のカリキュラムの一環として[iPresence合同会社](https://ipresence.jp/)で企業内実習を行い、Flutterでのビデオチャットアプリ開発に携わらせていただいた[大阪国際工科専門職大学](https://www.iput.ac.jp/osaka/)の3年生です。実習中に使用したLiveKitについてFlutterアプリケーションの開発に使用した記事が少なく苦戦したため事例として共有すべく、本記事ではLiveKitを用いた基本的な機能をもつビデオチャットの実装方法について記述します。

# LiveKitとは
　LiveKitとは、オープンソースのWebRTCツールです。様々な言語での実装が可能でサーバー構築の必要がなく、トークンの生成等を除いてクライアントサイドの実装のみで大規模なビデオチャットツールやライブストリーミングサービスを作ることが出来ます。GithubではZoomのようなビデオチャットがサンプルコードとして公開されており、APIキーを取得すれば簡単に動作の様子を確認することができます。またUDPベースの高速な通信が行えることから、リアルタイム性が求められるシステムのデータの送受信にも適しています。
 
# 作成するアプリケーション
　今回、例として作成するのは1対1で通話を行うビデオチャットアプリです。Livekitの公式ドキュメントから基本的な機能を抜粋して、お互いのデバイスで取得した映像を表示し、カメラ及びマイクのミュートとミュート解除機能、テキストデータの送受信機能を持つアプリを作成します。サーバー上でトークンを生成した場合のシステム構成図は以下の通りですが、トークンを手動で生成して直接コードに組み込むことも可能です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616703/1faba832-7645-16ad-9f27-9436689d554a.png)

 

# LiveKitAPIキーの取得
　LiveKitの[公式サイト](https://livekit.io/)からアカウントの登録を行い、新しいプロジェクトを作成してください。その後、Setting→Keysを選択し、AddNewKeyをクリックしてください。キーの名前を入力すると、WEBSOCKET URL、API KEY、SECRET KEYの3つが表示されます。API KEYとSECRET KEYについては今後確認できないので、別に保存しておいてください。

**トークンの生成方法**
KeysからGeneratetokenを選択してusernameとroomnameを入力してください。
同じルームに入るトークンを2つ用意したい場合、roomnameは同じにし、usernameは別のものを入力するようにしてください。TTLを変更することでトークンの使用期限を変更することができます。


# 事前準備
　LiveKitSDKのインストールを行ってください。

```dart:pubspec.yaml
dependencies:
  livekit_client: ^1.5.2
  flutter:
    sdk: flutter
```
```dart:main.dart
import 'dart:convert';
import 'package:livekit_client/livekit_client.dart';
```
利用した環境は以下の通りです。
- Flutter　ver3.13.6
- DartSDK　ver3.1.3
- DevTools　ver2.25.0

# roomへの接続
roomとはLiveKitの通話グループを表すオブジェクトです。roomは最初の参加者が接続したとき自動的に生成され、参加者が0になれば破棄されます。roomの参加者はビデオやオーディオなどの情報を持つ固有のTrackを発信し、他の利用者のTrackやイベントを取得することでビデオ通話を行うことができます。

下のコードでは、
<font color="Green">TrackSubscribedEvent</font>で他の参加者のTrackを取得したイベントを検知し、RemoteParticipant(相手側の参加者)を取得しています。

```dart:main.dart
class _MyHomePageState extends State<MyHomePage> {
  final roomOptions = const RoomOptions(
    adaptiveStream: true,
    dynacast: true,
  );

  Participant<TrackPublication<Track>>? localParticipant; //自分側
  Participant<TrackPublication<Track>>? remoteParticipant; //相手側
  Room? roomstate;

  @override
  void initState() {
    super.initState();
    connectToLivekit();
  }

  connectToLivekit() async {
    const url = 'WebSocketURL'; //LivekitのKey
    const token = 'token'; //LivekitのKey
    final room = Room(roomOptions: roomOptions);
    roomstate = room;

    room.createListener().on<TrackSubscribedEvent>((event) {
      //他の参加者の接続
      print('-----track event : $event');
      setState(() {
        remoteParticipant = event.participant;
      });
    });

    try {
      await room.connect(url, token);
    } catch (_) {
      print('Failed : $_');
    }

    setState(() {
      localParticipant = room.localParticipant!;
    });

    await room.localParticipant!.setCameraEnabled(true); //カメラの接続
    await room.localParticipant!.setMicrophoneEnabled(true); //マイクの接続
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          children: [
            // local video
            localParticipant != null
                ? Expanded(child: ParticipantWidget(localParticipant!))
                : const CircularProgressIndicator(),
            // remote video
            remoteParticipant != null
                ? Expanded(child: ParticipantWidget(remoteParticipant!))
                : const CircularProgressIndicator(),
          ],
        ),
      ),
    );
  }
}

```
# 通話映像を表示するウィジットを作成

公式ドキュメントの[Receiving tracks](https://docs.livekit.io/realtime/client/receive/)とほぼ同じです。不要な変数の削除のみ行いました。

```dart:main.dart
class ParticipantWidget extends StatefulWidget {
  final Participant participant;
  ParticipantWidget(this.participant);

  @override
  State<StatefulWidget> createState() {
    return _ParticipantState();
  }
}

class _ParticipantState extends State<ParticipantWidget> {
  TrackPublication? videoPub;

  @override
  void initState() {
    super.initState();
    widget.participant.addListener(_onChange);
  }

  @override
  void dispose() {
    super.dispose();
    widget.participant.removeListener(_onChange);
  }

  void _onChange() {
    var visibleVideos = widget.participant.videoTracks.where((pub) {
      return pub.kind == TrackType.VIDEO && pub.subscribed && !pub.muted;
    });

    if (visibleVideos.isNotEmpty) {
      setState(() {
        videoPub = visibleVideos.first;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (videoPub != null) {
      return VideoTrackRenderer(videoPub.track as VideoTrack);
    } else {
      return Container(
        color: Colors.grey,
      );
    }
  }
}

```
# ウィジットの呼び出し
下のウィジットを追加します。
```dart:main.dart
return Scaffold(
      body: Center(
        child: Column(
          children: [
            // local video
            localParticipant != null
                ? Expanded(child: ParticipantWidget(localParticipant!))
                : const CircularProgressIndicator(),
            // remote video
            remoteParticipant != null
                ? Expanded(child: ParticipantWidget(remoteParticipant!))
                : const CircularProgressIndicator(),
          ],
        ),
      ),
    );
```
ここまでのコードで、Livekitのルームにアクセスし、ビデオ映像と音声を相互に表示できる最も簡易的なビデオチャットが作成できます。
# roomからの退出
roomの退出を行い、他の参加者に通知します。退出を行わずアプリを終了した場合、その後15秒間は参加者が存在している状態になります。
```dart:main.dart
room.disconnect();
```
今回は1対1のビデオチャットアプリなので相手がroomから退出し、<font color="Green">ParticipantDisconnectedEvent</font>を検知した際、自分もroomから退出します。
```dart:main.dart
..on<ParticipantDisconnectedEvent>((event) {
  print('-----disconnected event : $event');
  room.disconnect();
})
```

# 音声とカメラのミュート機能
音声とカメラの切り替えを行う関数をそれぞれ作成します。それぞれの関数をボタンなどのウィジットに連携させてください。
```dart:main.dart
  enableAudio() async {
    await roomstate?.localParticipant!.setMicrophoneEnabled(true);
  }
  disableAudio() async {
    await roomstate?.localParticipant!.setMicrophoneEnabled(false);
  }
  enableVideo() async {
    await roomstate?.localParticipant!.setCameraEnabled(true);
  }
  disableVideo() async {
    await roomstate?.localParticipant!.setCameraEnabled(false);
  }
```
# テキストデータの送受信
今回のアプリではビデオチャットとともに実装しましたが、WebRTCのチャネルを利用しているため高速にデータを送信することが可能な点を活用して、ビデオチャットは利用せずにゲームやロボットの制御コマンドの送信を行うのにも適しています。[公式サイトのroboticsページ](https://livekit.io/use-cases/robotics)

送信用関数
```dart:main.dart
  publishData() {
    roomstate?.localParticipant!.publishData(
      utf8.encode('Text'),
    );
```

受信時
<font color="Green">DataReceivedEvent</font>を検知した際、decodedに取得したデータを格納します。
```dart:main.dart
      ..on<DataReceivedEvent>((event) {
        String decoded = 'Failed to decode';
        try {
          decoded = utf8.decode(event.data);
        } catch (_) {
          print('Failed : $_');
        }
        print(decoded);
      });
```
# イベントの監視
　LiveKitはRoomEvent、ParticipantEventの2種類のイベントを監視することができます。それぞれRoom全体と特定の参加者に変更があった際に参加者全員に通知され、イベントの発生に応じて必要な機能を実装することで、参加者全員がリアルタイムに機能を使用することが可能になります。
 イベントの一覧がLiveKit公式ドキュメントの[Handling Events](https://docs.livekit.io/realtime/client/events/)で公開されています。

# トークンの生成
　例として作成したアプリとは異なりますが、実習中に作成した別のアプリでFirebase 上でのトークン生成の自動化についても実施したため、簡単に記述します。

 FirebaseでWebアプリをデプロイしたため、FirebaseCloudFunctionを用いて以下のようなトークンを生成する関数をサーバーサイドに実装しました。
トークンはクライアントアプリ内でも生成できますが、リバースエンジニアリングなどの脅威からAPIkey及びSecretkeyを保護するため、セキュリティの観点からサーバーサイドで実装します。

roomNameはアプリ起動時に作成し、ramdomNameはroomの入室直前のトークンの生成時に作成することで、アプリ内で作成したURLを共有し、同じroomかつ異なるrandomName(参加者固有のID)で入室できるようにしました。


**クライアントサイドでの関数呼び出し**
アプリ起動時に作成したランダムな値uuidを引数として取得してramdomNameとともにrequestBodyに格納した後、引数としてgenerateTokenを呼び出し、その結果をresultsに代入しています。

```dart:main.dart
  @override
  Future<void> generateToken(uuid) async {
    HttpsCallable callable =
        FirebaseFunctions.instance.httpsCallable('generateToken');
    final randomname = Uuid().v4();
    final Map<String, dynamic> requestBody = {
      'roomName': uuid,
      'randomname': randomname
    };
    final results = await callable(requestBody);
    Map<String, dynamic> data = results.data;

    String token = data['token'];
    state = state.copyWith(token: token);
    print("generateToken finished");
  }
```
**サーバーサイド**
Livekit公式ドキュメントの[Generating tokens](https://docs.livekit.io/realtime/server/generating-tokens)をもとに作成しました。

```typescript:index.ts
import {onRequest} from "firebase-functions/v2/https";
import {AccessToken} from 'livekit-server-sdk';

export const onGenerateToken = onRequest((request, response) => { 
    const roomName = request.body.data.roomName;
    const participantName = request.body.data.randomname;

    const at = new AccessToken('APIKey', 'SecretKey', {
      identity: participantName,
    });
    at.addGrant({ roomJoin: true, room: roomName});
      response.json({ data: { token: at.toJwt() } });
});
```
**トークン生成の補足**
生成したトークンはhttps://jwt.io/ でデバックすることができます。
ペイロード部分に指定したroomNameとparticipantNameが追加されていることを確認してください。
また、FlutterアプリケーションでFirebaseCloudFunctionを用いる方法については[Flutterfire](https://firebase.flutter.dev/docs/overview/)を確認してください。



# まとめ
普段の大学の講義の中ではQiitaやブログ等で解説記事のあるツールしか使わないため、今回のように公式のドキュメントを読んで作業すること自体が新鮮でとても難しかったです。本記事はほとんどが公式ドキュメントに記載されている内容であり、ビデオチャットアプリとして最低限の機能を抜粋した形にはなりますが、お役に立てば幸いです。
また、この場をお借りして1か月半という長い期間お世話になりましたiPresenceの皆様にお礼を申し上げたいと思います。誠にありがとうございました。

# 参考文献
https://firebase.flutter.dev/docs/overview/
https://nishinatoshiharu.com/jwt-overview/
https://docs.livekit.io/realtime/
https://firebase.flutter.dev/docs/overview/