こんにちは!
弊社では毎月、部署の定例会をオンラインで行なってます。
一箇所に集まらなくても開催できるので非常にいいのですが、オンラインにおけるコミュニケーションは、今後も課題があると感じてます。
今回は、オンラインの定例会における要望の一つ、コメントを流せるオンライン会議システムを作ってみます。
なぜコメント機能が必要なのか
色々と試した結果、コメントが流れる方が盛り上がるからです。
身内での会議限定となりますが、匿名で様々なコメントが流れると、場が和み、より発言しやすくなります。
Google MeetやZoomでもリアクション機能やチャット機能はありますが、チャットだと気付きにくいこともあり、画面上で流れた方が分かりやすいです。
また、一時期cometsというサービスを利用していたのですが、Googleのセキュリティ強化により利用しづらくなった、という背景もあります。
やりたいこと
- オンライン会議
- コメントを送ったら画面上で流れる
環境構築
Pythonの仮想環境を作ります。
python -m venv online-meeting
cd online-meeting
ディレクトリに移動した直後の状態
$ ls
bin/ include/ lib/ pyvenv.cfg
仮想環境の有効化
source bin/activate
次に、必要なライブラリのインストールをします。
pip install flask
pip install qrcode[pil]
pip install flask-socketio
Flask-SocketIOとは
クライアントとサーバー間の双方向通信を実現し、低レイテンシでのアクセスが可能になるライブラリです。
ディレクトリ構成
最終的なディレクトリ構成です。
online-meeting/
├─ app.py
├─ pyvenv.cfg
├─ bin/
├─ include/
├─ lib/
├─ share/
└─ templates/
├─ index.html
└─ meeting.html
コード
今回実装した3ファイルを以下に示します。
QRコードは未完成のため、一旦コメントアウトしてます。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>オンライン会議システム</title>
</head>
<body>
<h1>オンライン会議システムへようこそ</h1>
<button id="create-meeting">新しい会議を作成</button>
<h2>会議に参加する</h2>
<form action="/join_meeting" method="post">
<label for="meeting-id">会議ID:</label>
<input type="text" id="meeting-id" name="meeting_id" required>
<button type="submit">参加</button>
</form>
<script>
document.getElementById('create-meeting').addEventListener('click', function() {
fetch('/create_meeting', { method: 'POST' })
.then(response => response.json())
.then(data => {
if (data.meeting_id) {
window.location.href = '/meeting/' + data.meeting_id;
}
})
.catch(error => console.error('Error:', error));
});
</script>
</body>
</html>
<!-- meeting.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>オンライン会議</title>
<script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.0/socket.io.js"></script>
<style>
#video-container {
position: relative;
width: 640px;
height: 360px;
overflow: hidden;
}
#video-container video {
width: 100%;
height: auto;
}
#comments-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
color: white;
background: rgba(0, 0, 0, 0.5);
}
.scroll-comment {
position: relative;
white-space: nowrap;
animation: scroll 10s linear infinite;
}
@keyframes scroll {
0% { transform: translateX(100%); }
100% { transform: translateX(-100%); }
}
</style>
</head>
<body>
<h1>オンライン会議</h1>
<p>会議ID: {{ meeting_id }}</p>
<div id="video-container" style="position: relative;">
<div id="comments-overlay"></div>
<video id="localVideo" autoplay muted></video>
<video id="remoteVideo" autoplay></video>
</div>
<!-- <h2>QRコード</h2>
<img src="data:image/png;base64,{{ qr_img_data }}" alt="QR Code"> -->
<h2>コメント</h2>
<form id="comment-form">
<input type="text" id="comment" name="comment" placeholder="コメントを入力" required>
<button type="submit">送信</button>
</form>
<script>
// ビデオ要素を取得
var localVideo = document.getElementById('localVideo');
var remoteVideo = document.getElementById('remoteVideo');
var localStream;
var peerConnection;
var config = {
'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]
};
// ユーザーのメディアを取得して、ビデオ要素に設定
navigator.mediaDevices.getUserMedia({video: true, audio: true})
.then(function(stream) {
localVideo.srcObject = stream;
localStream = stream;
initializePeerConnection();
}).catch(function(error) {
console.log('メディアの取得に失敗しました:', error);
});
// WebRTCの接続を初期化
function initializePeerConnection() {
peerConnection = new RTCPeerConnection(config);
localStream.getTracks().forEach(track => {
peerConnection.addTrack(track, localStream);
});
peerConnection.ontrack = function(event) {
remoteVideo.srcObject = event.streams[0];
};
}
// WebSocketを使ったシグナリング
var socket = io();
socket.on('new_comment', function(data) {
if (data.meeting_id === "{{ meeting_id }}") {
loadComments([data.comment]);
}
});
// 通信を開始するための処理(offer/answer/candidate)
socket.on('offer', function(offer) {
peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
});
socket.on('answer', function(answer) {
peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
});
socket.on('candidate', function(candidate) {
peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
});
// 通信のオファーを作成
function createOffer() {
peerConnection.createOffer()
.then(function(offer) {
return peerConnection.setLocalDescription(offer);
})
.then(function() {
// オファーをサーバーに送信
socket.emit('offer', peerConnection.localDescription);
});
}
var localVideo = document.getElementById('localVideo');
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(function(stream) {
localVideo.srcObject = stream;
})
.catch(function(error) {
console.log('メディアの取得に失敗しました: ', error);
});
// コメントの送信フォームのイベントリスナー
document.getElementById('comment-form').addEventListener('submit', function(event) {
event.preventDefault();
var comment = document.getElementById('comment').value;
fetch('/comment', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'meeting_id={{ meeting_id }}&comment=' + encodeURIComponent(comment)
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
document.getElementById('comment').value = '';
}
})
.catch(error => console.error('Error:', error));
});
function loadComments(newComments) {
var commentsOverlay = document.getElementById('comments-overlay');
// 新しいコメントを追加
newComments.forEach(function(comment, index) {
var p = document.createElement('p');
p.textContent = comment;
p.classList.add('scroll-comment');
p.style.top = ((commentsOverlay.children.length + index) * 20) % 200 + 'px';
setTimeout(function() {
p.remove();
}, 5000);
commentsOverlay.appendChild(p);
});
}
// 初回のコメント読み込み
fetch('/get_comments/{{ meeting_id }}')
.then(response => response.json())
.then(comments => {
loadComments(comments);
})
.catch(error => console.error('Error:', error));
loadComments();
</script>
</body>
</html>
# app.py
from flask import Flask, render_template, request, jsonify
from flask_socketio import SocketIO, send
import uuid
import qrcode
import io
import base64
app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")
# 会議データを保持する簡易データベース
meetings = {}
# トップページ
@app.route('/')
def index():
return render_template('index.html')
# 新しい会議の作成
@app.route('/create_meeting', methods=['POST'])
def create_meeting():
meeting_id = str(uuid.uuid4())
meeting_url = request.host_url + 'meeting/' + meeting_id
meetings[meeting_id] = {'url': meeting_url, 'comments': []}
return jsonify({'meeting_id': meeting_id, 'meeting_url': meeting_url})
# 会議への参加
@app.route('/meeting/<meeting_id>')
def meeting(meeting_id):
if meeting_id not in meetings:
return "Meeting not found", 404
# 会議URLのQRコード生成
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
qr.add_data(meetings[meeting_id]['url'])
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
byte_io = io.BytesIO()
img.save(byte_io, 'PNG')
byte_io.seek(0)
qr_img_data = base64.b64encode(byte_io.read()).decode('utf-8') # Base64エンコード
return render_template('meeting.html', meeting_id=meeting_id, qr_img_data=qr_img_data)
# コメントの送信
@app.route('/comment', methods=['POST'])
def post_comment():
meeting_id = request.form['meeting_id']
comment = request.form['comment']
if meeting_id in meetings:
meetings[meeting_id]['comments'].append(comment)
socketio.emit('new_comment', {'meeting_id': meeting_id, 'comment': comment})
return jsonify({'status': 'success'})
return jsonify({'status': 'failed'})
# コメントの取得
@app.route('/get_comments/<meeting_id>')
def get_comments(meeting_id):
if meeting_id in meetings:
return jsonify(meetings[meeting_id]['comments'])
return jsonify([])
# WebSocketイベントハンドラー
@socketio.on('message')
def handle_message(message):
send(message, broadcast=True)
if __name__ == '__main__':
app.run(host = '0.0.0.0',debug=True)
実行結果
それでは、実行してみましょう。
$ python app.py
* Serving Flask app 'app'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:5000
* Running on http://192.168.1.6:5000
トップページが表示されました。
次に、新しい会議を作成してみましょう。
カメラが起動して、画面に自分が映りました。
コメントを流してみましょう。
ちゃんと流れてますね!
さらに他のブラウザでもアクセスして、双方向からコメントを流してみましょう。
ChromeとEdgeからコメントを流してみたところ、ちゃんと確認できました!
基本的な部分はクリアできたようです。
今後の改修方針
まだまだ未完成なので、以下を可能なものから少しずつ対応していく予定です。(思いつき含む)
・参加メンバーの枠を表示する
・スマホでも動作するように改修
・リアクション機能
・画面共有機能
・Google認証(組織内だけ通す)
・セキュリティ強化(脆弱性対応など)
・AIの組み込み(文字起こし、翻訳、要約、タスク管理、その他)
・タイムキーパー機能
・ルーレット機能(強制的に意見を求める)
・多数決機能
・表彰機能(派手なやつ)
・逆投げ銭機能(臨時ボーナス的なやつ)
・NFT機能(参加、発表などの記録)
・バーチャル会議室機能(VR/AR)
まとめ
今回はFlaskでオンライン会議システムを作ってみました。
やりたいことを挙げたらきりがないですが、オンラインの課題を解決できるような、楽しいシステムになるといいな、と思います。
一瞬、Qiitaでもコメントが流れたら、コメントする人が増えて面白いかな、と思いましたが、良し悪しもあると思うので、もう少し考えてみます。
モノづくりって楽しいですね