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

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

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

早押し画面
早押し結果画面
クイズに対して早押しした人の名前がホスト画面に表示されます。
早い順に5名(最大20名まで)表示されます。

リセットボタンを押すと、参加者全員のボタンが押せる状態に戻ります。
これから年末に向けて色んなイベントで使えそうだな・・・以上です
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)
