1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

早押しクイズボタンアプリ作ってみた

1
Last updated at Posted at 2025-11-19

早押しクイズボタンアプリ作ってみた

最近、DJやってて余興でイントロ当てクイズとかやるので、早押しボタン欲しいな~と思ってたけど、Amazonとかで調べると結構高価なのよねぇ・・・
というわけでスマホで早押しできるWebアプリを作ってみました。
ひとまずWindowsPC用ですが、Pythonなのでライブラリさえ準備できればmacとかlinuxでも動作すると思う。

起動画面

アプリをダブルクリックすると、このような画面が表示されるので、ホスト側のURLをブラウザに入力。
image.png

ホスト画面

するとこんな画面が表示されるので、早押し参加者はホストPCと同じWIFIに入ったうえで、QRコードを読み取ります。
image.png

参加者スマホ画面

するとスマホにこんな画面が表示されるので、自分の名前やペンネーム入れて登録。
image.png

早押し画面

そうすると早押しボタンの画面になります。
image.png

早押し結果画面

クイズに対して早押しした人の名前がホスト画面に表示されます。
早い順に5名(最大20名まで)表示されます。
image.png
リセットボタンを押すと、参加者全員のボタンが押せる状態に戻ります。

これから年末に向けて色んなイベントで使えそうだな・・・以上です

from gevent import monkey
monkey.patch_all()

from flask import Flask, render_template_string, request, jsonify
from flask_socketio import SocketIO, emit
import time
from datetime import datetime
import qrcode
import io
import base64
from threading import Lock

app = Flask(__name__)
app.config['SECRET_KEY'] = 'quiz_buzzer_secret'
socketio = SocketIO(app, cors_allowed_origins="*", async_mode='gevent')

# グローバル変数
buzzers = []  # [{'username': 'user1', 'timestamp': 123456789.123}, ...]
max_display = 5
lock = Lock()

# ホスト側のHTML
HOST_HTML = """
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>早押しクイズ - ホスト画面</title>
    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <style>
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            max-width: 800px;
            margin: 50px auto;
            padding: 20px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
        }
        .container {
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
        }
        h1 {
            color: #333;
            text-align: center;
            margin-bottom: 10px;
        }
        .url-info {
            background: #f0f0f0;
            padding: 15px;
            border-radius: 8px;
            margin-bottom: 20px;
            text-align: center;
        }
        .url-info strong {
            font-size: 18px;
            color: #667eea;
        }
        #qrcodeContainer {
            margin: 15px 0;
            display: flex;
            justify-content: center;
        }
        #qrcodeContainer img {
            max-width: 200px;
            border: 3px solid #667eea;
            border-radius: 8px;
            padding: 10px;
            background: white;
        }
        .controls {
            display: flex;
            gap: 15px;
            margin-bottom: 30px;
            align-items: center;
            justify-content: center;
        }
        button {
            padding: 12px 30px;
            font-size: 16px;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s;
            font-weight: bold;
        }
        .reset-btn {
            background: #e74c3c;
            color: white;
        }
        .reset-btn:hover {
            background: #c0392b;
            transform: scale(1.05);
        }
        .setting {
            display: flex;
            align-items: center;
            gap: 10px;
        }
        .setting input {
            width: 60px;
            padding: 8px;
            border: 2px solid #ddd;
            border-radius: 5px;
            font-size: 16px;
        }
        #buzzerList {
            list-style: none;
            padding: 0;
        }
        .buzzer-item {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 20px;
            margin: 10px 0;
            border-radius: 10px;
            font-size: 20px;
            font-weight: bold;
            display: flex;
            align-items: center;
            gap: 15px;
            animation: slideIn 0.3s ease-out;
        }
        @keyframes slideIn {
            from {
                transform: translateX(-100%);
                opacity: 0;
            }
            to {
                transform: translateX(0);
                opacity: 1;
            }
        }
        .rank {
            background: white;
            color: #667eea;
            width: 40px;
            height: 40px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 24px;
        }
        .rank.first {
            background: gold;
            color: #333;
        }
        .no-buzzers {
            text-align: center;
            color: #999;
            font-size: 18px;
            padding: 40px;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>🔔 早押しクイズ - ホスト画面</h1>
        <div class="url-info">
            <p>参加者用URL: <strong id="clientUrl"></strong></p>
            <div id="qrcodeContainer">
                <img id="qrcodeImage" alt="QRコード読み込み中..." src="">
            </div>
            <p style="color: #666; font-size: 14px;">スマートフォンでQRコードを読み取るか、URLにアクセスしてください</p>
        </div>
        <div class="controls">
            <button class="reset-btn" onclick="resetBuzzers()">リセット</button>
            <div class="setting">
                <label>表示人数:</label>
                <input type="number" id="maxDisplay" value="5" min="1" max="20" onchange="updateMaxDisplay()">
            </div>
        </div>
        <h2 style="color: #333; margin-bottom: 20px;">回答順位</h2>
        <ul id="buzzerList">
            <li class="no-buzzers">回答待ち...</li>
        </ul>
    </div>
    <audio id="buzzerSound" preload="auto"></audio>
    
    <script>
        // ピンポン音を生成
        const audioContext = new (window.AudioContext || window.webkitAudioContext)();
        
        function playBuzzerSound() {
            const oscillator1 = audioContext.createOscillator();
            const oscillator2 = audioContext.createOscillator();
            const gainNode = audioContext.createGain();
            
            oscillator1.connect(gainNode);
            oscillator2.connect(gainNode);
            gainNode.connect(audioContext.destination);
            
            oscillator1.frequency.value = 800;
            oscillator2.frequency.value = 1000;
            oscillator1.type = 'sine';
            oscillator2.type = 'sine';
            
            gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
            gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
            
            oscillator1.start(audioContext.currentTime);
            oscillator2.start(audioContext.currentTime + 0.1);
            oscillator1.stop(audioContext.currentTime + 0.5);
            oscillator2.stop(audioContext.currentTime + 0.6);
        }
        
        const socket = io();
        let maxDisplay = 5;
        let hasPlayedSound = false;
        
        // クライアント用URLを表示
        document.getElementById('clientUrl').textContent = 
            window.location.protocol + '//' + window.location.host + '/client';
        
        // QRコードを読み込み
        fetch('/qrcode')
            .then(response => {
                if (!response.ok) {
                    throw new Error('QRコード取得失敗');
                }
                return response.json();
            })
            .then(data => {
                if (data.error) {
                    throw new Error(data.error);
                }
                document.getElementById('qrcodeImage').src = data.qrcode;
                console.log('QRコード表示成功');
            })
            .catch(error => {
                console.error('QRコード生成エラー:', error);
                document.getElementById('qrcodeImage').alt = 'QRコード生成に失敗しました';
                document.getElementById('qrcodeImage').style.display = 'none';
            });
        
        socket.on('buzzer_update', function(data) {
            console.log('buzzer_update受信:', data);
            updateBuzzerList(data.buzzers);
            
            // 最初の回答時のみ音を鳴らす
            if (data.buzzers.length === 1 && !hasPlayedSound) {
                playBuzzerSound();
                hasPlayedSound = true;
            }
        });
        
        socket.on('buzzer_reset', function() {
            console.log('buzzer_reset受信');
            buzzers = [];
            hasPlayedSound = false;
            updateBuzzerList([]);
        });
        
        function updateBuzzerList(buzzers) {
            const list = document.getElementById('buzzerList');
            list.innerHTML = '';
            
            console.log('リスト更新:', buzzers);
            
            if (buzzers.length === 0) {
                list.innerHTML = '<li class="no-buzzers">回答待ち...</li>';
                return;
            }
            
            const displayBuzzers = buzzers.slice(0, maxDisplay);
            displayBuzzers.forEach((buzzer, index) => {
                const li = document.createElement('li');
                li.className = 'buzzer-item';
                li.innerHTML = `
                    <span class="rank ${index === 0 ? 'first' : ''}">${index + 1}</span>
                    <span>${buzzer.username}</span>
                `;
                list.appendChild(li);
            });
        }
        
        function resetBuzzers() {
            console.log('リセットボタン押下');
            fetch('/reset', { 
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                }
            })
                .then(response => {
                    console.log('リセットレスポンス:', response.status);
                    if (!response.ok) {
                        throw new Error('リセット失敗');
                    }
                    return response.json();
                })
                .then(data => {
                    console.log('リセット完了:', data);
                })
                .catch(error => {
                    console.error('リセットエラー:', error);
                });
        }
        
        function updateMaxDisplay() {
            maxDisplay = parseInt(document.getElementById('maxDisplay').value);
            socket.emit('update_max_display', { max_display: maxDisplay });
        }
    </script>
</body>
</html>
"""

# クライアント側のHTML
CLIENT_HTML = """
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>早押しクイズ - 参加者</title>
    <script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        .container {
            width: 100%;
            max-width: 400px;
        }
        .card {
            background: white;
            border-radius: 15px;
            padding: 30px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.3);
        }
        h1 {
            color: #333;
            text-align: center;
            margin-bottom: 30px;
            font-size: 24px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            color: #555;
            margin-bottom: 8px;
            font-weight: bold;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 8px;
            font-size: 16px;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 15px;
            font-size: 18px;
            font-weight: bold;
            border: none;
            border-radius: 8px;
            cursor: pointer;
            transition: all 0.3s;
        }
        .register-btn {
            background: #667eea;
            color: white;
        }
        .register-btn:hover {
            background: #5568d3;
        }
        .buzzer-btn {
            background: #e74c3c;
            color: white;
            font-size: 24px;
            padding: 60px;
            border-radius: 50%;
            width: 200px;
            height: 200px;
            margin: 20px auto;
            display: block;
            box-shadow: 0 5px 15px rgba(231, 76, 60, 0.4);
        }
        .buzzer-btn:hover:not(:disabled) {
            background: #c0392b;
            transform: scale(1.05);
        }
        .buzzer-btn:active:not(:disabled) {
            transform: scale(0.95);
        }
        .buzzer-btn:disabled {
            background: #95a5a6;
            cursor: not-allowed;
            opacity: 0.6;
        }
        .reset-client-btn {
            width: 100%;
            background: #f39c12;
            color: white;
            margin-top: 10px;
            padding: 12px;
            font-size: 16px;
        }
        .reset-client-btn:hover {
            background: #e67e22;
        }
        .status {
            text-align: center;
            margin-top: 20px;
            padding: 15px;
            border-radius: 8px;
            font-weight: bold;
        }
        .status.waiting {
            background: #f0f0f0;
            color: #666;
        }
        .status.pressed {
            background: #2ecc71;
            color: white;
        }
        .username-display {
            text-align: center;
            color: #667eea;
            font-size: 20px;
            font-weight: bold;
            margin-bottom: 20px;
        }
        #registrationForm {
            display: block;
        }
        #buzzerArea {
            display: none;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="card">
            <h1>🔔 早押しクイズ</h1>
            
            <div id="registrationForm">
                <div class="form-group">
                    <label>ユーザー名を入力</label>
                    <input type="text" id="username" placeholder="例: 山田太郎" maxlength="20">
                </div>
                <button class="register-btn" onclick="register()">登録</button>
            </div>
            
            <div id="buzzerArea">
                <div class="username-display">
                    <span id="displayUsername"></span>
                </div>
                <button class="buzzer-btn" id="buzzerBtn" onclick="pressBuzzer()">
                    押す!
                </button>
                <div class="status waiting" id="status">
                    回答待機中
                </div>
                <button class="reset-client-btn" onclick="resetMyStatus()">
                    リセット(自分のみ)
                </button>
            </div>
        </div>
    </div>
    
    <script>
        const socket = io();
        let username = '';
        let hasPressed = false;
        
        function register() {
            const input = document.getElementById('username').value.trim();
            if (!input) {
                alert('ユーザー名を入力してください');
                return;
            }
            username = input;
            document.getElementById('registrationForm').style.display = 'none';
            document.getElementById('buzzerArea').style.display = 'block';
            document.getElementById('displayUsername').textContent = username;
        }
        
        function pressBuzzer() {
            if (hasPressed) return;
            
            socket.emit('buzzer_press', { username: username });
            hasPressed = true;
            
            updateButtonState();
        }
        
        function updateButtonState() {
            const btn = document.getElementById('buzzerBtn');
            const status = document.getElementById('status');
            
            if (hasPressed) {
                btn.disabled = true;
                status.className = 'status pressed';
                status.textContent = '回答しました!';
            } else {
                btn.disabled = false;
                status.className = 'status waiting';
                status.textContent = '回答待機中';
            }
        }
        
        function resetMyStatus() {
            hasPressed = false;
            updateButtonState();
        }
        
        socket.on('buzzer_reset', function() {
            console.log('リセット受信');
            hasPressed = false;
            updateButtonState();
        });
    </script>
</body>
</html>
"""

@app.route('/')
def host():
    return render_template_string(HOST_HTML)

@app.route('/qrcode')
def generate_qrcode():
    try:
        # クライアントURLを取得
        host_url = request.host_url
        client_url = f"{host_url}client"
        
        print(f"QRコード生成中: {client_url}")
        
        # QRコード生成
        qr = qrcode.QRCode(
            version=1,
            error_correction=qrcode.constants.ERROR_CORRECT_L,
            box_size=10,
            border=4,
        )
        qr.add_data(client_url)
        qr.make(fit=True)
        
        img = qr.make_image(fill_color="black", back_color="white")
        
        # 画像をBase64エンコード
        buf = io.BytesIO()
        img.save(buf, format='PNG')
        buf.seek(0)
        img_base64 = base64.b64encode(buf.getvalue()).decode()
        
        print("QRコード生成成功")
        return jsonify({'qrcode': f'data:image/png;base64,{img_base64}'})
    except Exception as e:
        print(f"QRコード生成エラー: {e}")
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

@app.route('/client')
def client():
    return render_template_string(CLIENT_HTML)

@app.route('/reset', methods=['POST'])
def reset():
    global buzzers
    with lock:
        buzzers = []
    print("リセット実行 - 全データクリア")
    socketio.emit('buzzer_reset', namespace='/')
    return jsonify({'status': 'ok', 'buzzers': []}), 200

@socketio.on('buzzer_press')
def handle_buzzer_press(data):
    global buzzers
    username = data.get('username', '匿名')
    
    with lock:
        # 同じユーザーが既に押していないかチェック
        if any(b['username'] == username for b in buzzers):
            return
        
        buzzers.append({
            'username': username,
            'timestamp': time.time()
        })
        
        # タイムスタンプでソート
        buzzers.sort(key=lambda x: x['timestamp'])
    
    socketio.emit('buzzer_update', {'buzzers': buzzers}, namespace='/')

@socketio.on('update_max_display')
def handle_max_display_update(data):
    global max_display
    max_display = data.get('max_display', 5)

if __name__ == '__main__':
    import socket as sock
    
    # ローカルIPアドレスを取得
    try:
        # 外部接続を試みてローカルIPを取得
        s = sock.socket(sock.AF_INET, sock.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))
        local_ip = s.getsockname()[0]
        s.close()
    except Exception:
        # 失敗した場合はlocalhostを使用
        local_ip = '127.0.0.1'
    
    print("\n" + "="*60)
    print("早押しクイズアプリが起動しました!")
    print("="*60)
    print(f"\n【ホスト側】PCのブラウザで以下にアクセス:")
    if local_ip != '127.0.0.1':
        print(f"  http://{local_ip}:5000")
    print(f"\n【参加者側】スマホのブラウザで以下にアクセス:")
    if local_ip != '127.0.0.1':
        print(f"  http://{local_ip}:5000/client")
    else:
        print(f"  PCのローカルIPアドレスを確認して接続してください")
    print(f"\n  ※ホスト画面にQRコードが表示されます")
    print("\n" + "="*60 + "\n")
    
    socketio.run(app, host='0.0.0.0', port=5000, debug=False)
    
    
1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?