10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

こんにちは!

弊社では毎月、部署の定例会をオンラインで行なってます。
一箇所に集まらなくても開催できるので非常にいいのですが、オンラインにおけるコミュニケーションは、今後も課題があると感じてます。

今回は、オンラインの定例会における要望の一つ、コメントを流せるオンライン会議システムを作ってみます。

なぜコメント機能が必要なのか

色々と試した結果、コメントが流れる方が盛り上がるからです。
身内での会議限定となりますが、匿名で様々なコメントが流れると、場が和み、より発言しやすくなります。
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コードは未完成のため、一旦コメントアウトしてます。

index.html
<!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
<!-- 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
# 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

127.0.0.1:5000 にアクセスしてみます。
スクリーンショット 2023-12-18 0.03.43.png

トップページが表示されました。
次に、新しい会議を作成してみましょう。
スクリーンショット 2023-12-18 0.06.05.png

カメラが起動して、画面に自分が映りました。
コメントを流してみましょう。
スクリーンショット 2023-12-18 0.09.45.png

ちゃんと流れてますね!
さらに他のブラウザでもアクセスして、双方向からコメントを流してみましょう。
スクリーンショット 2023-12-18 0.11.53.png

ChromeとEdgeからコメントを流してみたところ、ちゃんと確認できました!
基本的な部分はクリアできたようです。

今後の改修方針

まだまだ未完成なので、以下を可能なものから少しずつ対応していく予定です。(思いつき含む)
・参加メンバーの枠を表示する
・スマホでも動作するように改修
・リアクション機能
・画面共有機能
・Google認証(組織内だけ通す)
・セキュリティ強化(脆弱性対応など)
・AIの組み込み(文字起こし、翻訳、要約、タスク管理、その他)
・タイムキーパー機能
・ルーレット機能(強制的に意見を求める)
・多数決機能
・表彰機能(派手なやつ)
・逆投げ銭機能(臨時ボーナス的なやつ)
・NFT機能(参加、発表などの記録)
・バーチャル会議室機能(VR/AR)

まとめ

今回はFlaskでオンライン会議システムを作ってみました。
やりたいことを挙げたらきりがないですが、オンラインの課題を解決できるような、楽しいシステムになるといいな、と思います。

一瞬、Qiitaでもコメントが流れたら、コメントする人が増えて面白いかな、と思いましたが、良し悪しもあると思うので、もう少し考えてみます。

モノづくりって楽しいですね:smiley:

10
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?