From 4659049cef533bd1c9c72a8977852f137a98308a Mon Sep 17 00:00:00 2001 From: Hogu Date: Mon, 28 Sep 2026 11:47:44 +0200 Subject: [PATCH] =?UTF-8?q?oupsi=20avec=20la=20pi=C3=A8ce=20jointe=20c'est?= =?UTF-8?q?=20mieu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 + .gitea/workflows/deploy.yml | 15 +++ Dockerfile | 10 ++ docker-compose.yml | 8 ++ package.json | 12 +++ public/buzzer.html | 109 +++++++++++++++++++ public/index.html | 202 ++++++++++++++++++++++++++++++++++++ public/style.css | 55 ++++++++++ server.js | 179 ++++++++++++++++++++++++++++++++ 9 files changed, 593 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 public/buzzer.html create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c02037b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +.git +.gitea diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..2e010b9 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,15 @@ +name: Deploy +on: + push: + branches: [master] + +jobs: + deploy: + # Doit correspondre à un label de ton act_runner, installé sur le serveur qui héberge Docker + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Build et redémarrage du conteneur + run: docker compose up -d --build --remove-orphans + - name: Nettoyage des anciennes images + run: docker image prune -f diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65b78eb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm install --omit=dev +COPY . . +ENV NODE_ENV=production +EXPOSE 3000 +USER node +HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/healthz || exit 1 +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..60f8ab8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +name: blindtest +services: + blindtest: + build: . + container_name: blindtest + restart: unless-stopped + ports: + - "3000:3000" diff --git a/package.json b/package.json new file mode 100644 index 0000000..310bb94 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "blindtest", + "version": "1.0.0", + "private": true, + "main": "server.js", + "scripts": { "start": "node server.js" }, + "dependencies": { + "express": "^4.21.0", + "qrcode": "^1.5.4", + "socket.io": "^4.8.0" + } +} diff --git a/public/buzzer.html b/public/buzzer.html new file mode 100644 index 0000000..482bc91 --- /dev/null +++ b/public/buzzer.html @@ -0,0 +1,109 @@ + + + + + + Blind Test – Buzzer + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..723cf60 --- /dev/null +++ b/public/index.html @@ -0,0 +1,202 @@ + + + + + + Blind Test – Master + + + +
+

🎧 Blind Test

+ +
+
+ +
+
+
+ QR code pour rejoindre la partie +

+
+
+

Nouveau morceau

+

Colle l'URL d'une vidéo YouTube puis valide.

+
+ + +
+

+ +
+
+
+ + + + + + +
+ + +
+
+ + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..f84c7ba --- /dev/null +++ b/public/style.css @@ -0,0 +1,55 @@ +:root{--bg:#0f1220;--card:#181c30;--card2:#222744;--text:#f2f4ff;--muted:#8d93b5;--accent:#7c5cff;--ok:#22c55e;--ko:#ef4444;--r:16px} +*{box-sizing:border-box} +[hidden]{display:none!important} +body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;min-height:100vh} +h1,h2{margin:0} +h2{font-size:1.25rem;margin-bottom:12px} +.muted,.small{color:var(--muted)} +.small{font-size:.85rem;word-break:break-all} +.error{color:var(--ko);min-height:1.2em;margin:8px 0} +.card{background:var(--card);border-radius:var(--r);padding:24px} +.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center} +input[type=url],input[type=text]{flex:1;min-width:200px;padding:14px 16px;border-radius:12px;border:2px solid var(--card2);background:var(--bg);color:var(--text);font-size:1rem} +input:focus{outline:none;border-color:var(--accent)} +button{font:inherit;font-weight:700;border:none;border-radius:12px;padding:14px 22px;cursor:pointer;color:var(--text);background:var(--card2)} +button:hover{filter:brightness(1.15)} +button.primary{background:var(--accent)} +button.ok{background:var(--ok);color:#052e12} +button.ko{background:var(--ko)} +button.ghost{background:transparent;border:2px solid var(--card2);color:var(--muted)} +label{color:var(--muted);display:flex;gap:6px;align-items:center} + +/* Master */ +.master{max-width:1200px;margin:0 auto;padding:20px} +.master header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px} +.tag{background:var(--card);padding:6px 14px;border-radius:999px;color:var(--muted);font-weight:700;letter-spacing:.05em} +.layout{display:grid;grid-template-columns:1fr 300px;gap:20px;align-items:start} +@media(max-width:900px){.layout{grid-template-columns:1fr}} +.lobby-grid{display:grid;grid-template-columns:280px 1fr;gap:32px;align-items:center} +@media(max-width:700px){.lobby-grid{grid-template-columns:1fr}} +.qr img{width:100%;background:#fff;border-radius:12px;padding:8px;display:block} +.video{position:relative;aspect-ratio:16/9;background:#000;border-radius:12px;overflow:hidden} +.video iframe{position:absolute;inset:0;width:100%;height:100%;border:0} +.mask{position:absolute;inset:0;background:linear-gradient(135deg,#2b2250,#0f1220);display:flex;align-items:center;justify-content:center;font-size:6rem;z-index:2} +.status{font-size:1.6rem;font-weight:800;margin:20px 0 12px;min-height:2em} +.judge{display:flex;gap:16px;margin-bottom:16px} +.judge button{flex:1;font-size:1.6rem;padding:22px} +.board{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:8px} +.board li{display:flex;align-items:center;gap:10px;background:var(--card2);padding:10px 14px;border-radius:10px} +.board .name{flex:1;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.board .score{font-weight:800;font-size:1.1rem} +.dot{width:10px;height:10px;border-radius:50%;background:var(--ok)} +.dot.off{background:#555} +.board.final li{font-size:1.4rem;padding:16px 20px} +#endScreen{text-align:center} +#endScreen .board{margin:20px auto 28px;max-width:520px;text-align:left} + +/* Buzzer */ +.buzzer-page{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:24px;text-align:center;-webkit-user-select:none;user-select:none} +.buzzer-page .card{width:100%;max-width:420px} +.buzzer-page .row{flex-direction:column;align-items:stretch} +.me{display:flex;justify-content:space-between;width:100%;max-width:420px;font-weight:700} +#buzz{width:min(72vw,320px);aspect-ratio:1;border-radius:50%;font-size:2.6rem;font-weight:900;letter-spacing:.05em;color:#fff;background:radial-gradient(circle at 30% 30%,#ff7b7b,#d00000);box-shadow:0 12px 0 #7a0000,0 20px 40px rgba(0,0,0,.5);touch-action:manipulation;-webkit-tap-highlight-color:transparent;transition:transform .05s,box-shadow .05s} +#buzz:active:not(:disabled){transform:translateY(8px);box-shadow:0 4px 0 #7a0000} +#buzz:disabled{background:#3a3f5c;box-shadow:0 12px 0 #262a40;color:#8d93b5;font-size:1.3rem;cursor:not-allowed;padding:20px} +#msg{font-size:1.2rem;font-weight:700;min-height:1.5em} diff --git a/server.js b/server.js new file mode 100644 index 0000000..b866132 --- /dev/null +++ b/server.js @@ -0,0 +1,179 @@ +'use strict'; +const path = require('path'); +const http = require('http'); +const crypto = require('crypto'); +const express = require('express'); +const QRCode = require('qrcode'); +const { Server } = require('socket.io'); + +const PORT = process.env.PORT || 3000; +const app = express(); +const server = http.createServer(app); +const io = new Server(server); + +app.use(express.static(path.join(__dirname, 'public'))); +app.get('/healthz', (_req, res) => res.send('ok')); +app.get('/qr.svg', async (req, res) => { + const text = String(req.query.text || '').slice(0, 500); + if (!text) return res.status(400).end(); + const svg = await QRCode.toString(text, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' }); + res.type('image/svg+xml').send(svg); +}); + +/* ---------- État des rooms (en mémoire) ---------- */ +// phases : lobby | playing | buzzed | result | ended +const rooms = new Map(); +const ROOM_RE = /^[A-Z0-9]{4,8}$/; +const TOKEN_RE = /^[A-Za-z0-9-]{8,40}$/; +const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +function newRoomId() { + let id; + do { + id = Array.from({ length: 5 }, () => CHARS[crypto.randomInt(CHARS.length)]).join(''); + } while (rooms.has(id)); + return id; +} +function createRoom(id) { + const room = { id, phase: 'lobby', players: new Map(), buzzer: null, lockedOut: new Set(), lastActive: Date.now() }; + rooms.set(id, room); + return room; +} +function publicState(room) { + return { + phase: room.phase, + buzzer: room.buzzer, + lockedOut: [...room.lockedOut], + players: [...room.players.values()].map(p => ({ id: p.id, name: p.name, score: p.score, connected: p.connected })), + }; +} +function broadcast(room) { + room.lastActive = Date.now(); + io.to(room.id).emit('state', publicState(room)); +} +function masterRoom(socket) { + if (socket.data.role !== 'master') return null; + return rooms.get(socket.data.roomId) || null; +} + +io.on('connection', socket => { + /* ----- Master ----- */ + socket.on('master:create', (data, ack) => { + ack = typeof ack === 'function' ? ack : () => {}; + let id = String((data && data.roomId) || '').toUpperCase(); + let room = ROOM_RE.test(id) ? rooms.get(id) : null; + if (!room) room = createRoom(ROOM_RE.test(id) ? id : newRoomId()); + socket.data.role = 'master'; + socket.data.roomId = room.id; + socket.join(room.id); + ack({ roomId: room.id }); + broadcast(room); + }); + + socket.on('master:start', () => { + const room = masterRoom(socket); + if (!room || room.phase === 'ended') return; + room.phase = 'playing'; + room.buzzer = null; + room.lockedOut.clear(); + broadcast(room); + }); + + socket.on('master:judge', correct => { + const room = masterRoom(socket); + if (!room || room.phase !== 'buzzed' || !room.buzzer) return; + const player = [...room.players.values()].find(p => p.id === room.buzzer.id); + if (correct) { + if (player) player.score += 1; + room.phase = 'result'; // le buzzer reste affiché comme gagnant + } else { + room.lockedOut.add(room.buzzer.id); + room.buzzer = null; + room.phase = 'playing'; // la vidéo reprend, ce joueur est bloqué pour ce morceau + } + broadcast(room); + }); + + socket.on('master:next', () => { + const room = masterRoom(socket); + if (!room || room.phase === 'ended') return; + room.phase = 'lobby'; + room.buzzer = null; + room.lockedOut.clear(); + broadcast(room); + }); + + socket.on('master:end', () => { + const room = masterRoom(socket); + if (!room) return; + room.phase = 'ended'; + room.buzzer = null; + broadcast(room); + }); + + socket.on('master:newgame', () => { + const room = masterRoom(socket); + if (!room) return; + room.players.forEach(p => { p.score = 0; }); + room.phase = 'lobby'; + room.buzzer = null; + room.lockedOut.clear(); + broadcast(room); + }); + + /* ----- Joueurs ----- */ + socket.on('player:join', (data, ack) => { + ack = typeof ack === 'function' ? ack : () => {}; + data = data || {}; + const room = rooms.get(String(data.roomId || '').toUpperCase()); + if (!room) return ack({ error: 'no_room' }); + + const token = TOKEN_RE.test(data.token || '') ? data.token : null; + let player = token ? room.players.get(token) : null; + if (!player) { + const name = String(data.name || '').trim().replace(/\s+/g, ' ').slice(0, 20); + if (!name) return ack({ error: 'no_name' }); + const taken = [...room.players.values()].some(p => p.name.toLowerCase() === name.toLowerCase()); + if (taken) return ack({ error: 'name_taken' }); + const t = token || crypto.randomUUID(); + player = { id: crypto.randomBytes(4).toString('hex'), token: t, name, score: 0, connected: true }; + room.players.set(t, player); + } + player.connected = true; + socket.data.role = 'player'; + socket.data.roomId = room.id; + socket.data.token = player.token; + socket.join(room.id); + ack({ token: player.token, id: player.id, name: player.name }); + broadcast(room); + }); + + socket.on('player:buzz', () => { + if (socket.data.role !== 'player') return; + const room = rooms.get(socket.data.roomId); + const player = room && room.players.get(socket.data.token); + if (!room || !player) return; + if (room.phase !== 'playing' || room.lockedOut.has(player.id)) return; + room.phase = 'buzzed'; // le premier message reçu gagne + room.buzzer = { id: player.id, name: player.name }; + broadcast(room); + }); + + socket.on('disconnect', () => { + if (socket.data.role !== 'player') return; + const room = rooms.get(socket.data.roomId); + const player = room && room.players.get(socket.data.token); + if (player) { + player.connected = false; + broadcast(room); + } + }); +}); + +// Nettoyage des rooms inactives (12 h) +setInterval(() => { + const limit = Date.now() - 12 * 3600 * 1000; + for (const [id, room] of rooms) if (room.lastActive < limit) rooms.delete(id); +}, 30 * 60 * 1000).unref(); + +server.listen(PORT, () => console.log(`Blind test sur le port ${PORT}`));