はじめに
私は2023年10月より1か月半、大学のカリキュラムの一環としてiPresence合同会社で企業内実習を行い、Flutterでのビデオチャットアプリ開発に携わらせていただいた大阪国際工科専門職大学の3年生です。実習中に使用したLiveKitについてFlutterアプリケーションの開発に使用した記事が少なく苦戦したため事例として共有すべく、本記事ではLiveKitを用いた基本的な機能をもつビデオチャットの実装方法について記述します。
LiveKitとは
LiveKitとは、オープンソースのWebRTCツールです。様々な言語での実装が可能でサーバー構築の必要がなく、トークンの生成等を除いてクライアントサイドの実装のみで大規模なビデオチャットツールやライブストリーミングサービスを作ることが出来ます。GithubではZoomのようなビデオチャットがサンプルコードとして公開されており、APIキーを取得すれば簡単に動作の様子を確認することができます。またUDPベースの高速な通信が行えることから、リアルタイム性が求められるシステムのデータの送受信にも適しています。
作成するアプリケーション
今回、例として作成するのは1対1で通話を行うビデオチャットアプリです。Livekitの公式ドキュメントから基本的な機能を抜粋して、お互いのデバイスで取得した映像を表示し、カメラ及びマイクのミュートとミュート解除機能、テキストデータの送受信機能を持つアプリを作成します。サーバー上でトークンを生成した場合のシステム構成図は以下の通りですが、トークンを手動で生成して直接コードに組み込むことも可能です。
LiveKitAPIキーの取得
LiveKitの公式サイトからアカウントの登録を行い、新しいプロジェクトを作成してください。その後、Setting→Keysを選択し、AddNewKeyをクリックしてください。キーの名前を入力すると、WEBSOCKET URL、API KEY、SECRET KEYの3つが表示されます。API KEYとSECRET KEYについては今後確認できないので、別に保存しておいてください。
トークンの生成方法
KeysからGeneratetokenを選択してusernameとroomnameを入力してください。
同じルームに入るトークンを2つ用意したい場合、roomnameは同じにし、usernameは別のものを入力するようにしてください。TTLを変更することでトークンの使用期限を変更することができます。
事前準備
LiveKitSDKのインストールを行ってください。
dependencies:
livekit_client: ^1.5.2
flutter:
sdk: flutter
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やイベントを取得することでビデオ通話を行うことができます。
下のコードでは、
TrackSubscribedEventで他の参加者のTrackを取得したイベントを検知し、RemoteParticipant(相手側の参加者)を取得しています。
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とほぼ同じです。不要な変数の削除のみ行いました。
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,
);
}
}
}
ウィジットの呼び出し
下のウィジットを追加します。
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秒間は参加者が存在している状態になります。
room.disconnect();
今回は1対1のビデオチャットアプリなので相手がroomから退出し、ParticipantDisconnectedEventを検知した際、自分もroomから退出します。
..on<ParticipantDisconnectedEvent>((event) {
print('-----disconnected event : $event');
room.disconnect();
})
音声とカメラのミュート機能
音声とカメラの切り替えを行う関数をそれぞれ作成します。それぞれの関数をボタンなどのウィジットに連携させてください。
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ページ
送信用関数
publishData() {
roomstate?.localParticipant!.publishData(
utf8.encode('Text'),
);
受信時
DataReceivedEventを検知した際、decodedに取得したデータを格納します。
..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で公開されています。
トークンの生成
例として作成したアプリとは異なりますが、実習中に作成した別のアプリでFirebase 上でのトークン生成の自動化についても実施したため、簡単に記述します。
FirebaseでWebアプリをデプロイしたため、FirebaseCloudFunctionを用いて以下のようなトークンを生成する関数をサーバーサイドに実装しました。
トークンはクライアントアプリ内でも生成できますが、リバースエンジニアリングなどの脅威からAPIkey及びSecretkeyを保護するため、セキュリティの観点からサーバーサイドで実装します。
roomNameはアプリ起動時に作成し、ramdomNameはroomの入室直前のトークンの生成時に作成することで、アプリ内で作成したURLを共有し、同じroomかつ異なるrandomName(参加者固有のID)で入室できるようにしました。
クライアントサイドでの関数呼び出し
アプリ起動時に作成したランダムな値uuidを引数として取得してramdomNameとともにrequestBodyに格納した後、引数としてgenerateTokenを呼び出し、その結果をresultsに代入しています。
@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をもとに作成しました。
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を確認してください。
まとめ
普段の大学の講義の中では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/