initial commit

This commit is contained in:
Nicola
2026-06-24 13:00:51 +02:00
commit 2d4c3864ef
11 changed files with 2555 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clock</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: transparent;
color: #fff;
user-select: none;
}
.widget {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.clock {
background: rgba(0,0,0,0.5);
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: calc(min(8px, 0.6vw));
padding: 0.4em 1em;
font-size: clamp(16px, 2vw, 32px);
font-weight: 600;
letter-spacing: 1px;
color: rgba(255,255,255,0.7);
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.clock { animation: fadeIn 0.8s ease-out; }
</style>
</head>
<body>
<div class="widget">
<div class="clock" id="clock">00:00:00</div>
</div>
<script>
function updateClock() {
const now = new Date();
document.getElementById('clock').textContent =
now.toLocaleTimeString('it-IT', { hour12: false });
}
setInterval(updateClock, 1000);
updateClock();
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
/**
* common.js Stato condiviso tra i widget overlay via WebSocket
*
* Ogni widget si connette al server Express+WS e ascolta gli aggiornamenti.
* Il pannello di controllo invia comandi/aggiornamenti, il server li
* propaga a tutti i widget connessi.
*/
// ---- Config ----
const WS_URL = (() => {
const loc = window.location;
const protocol = loc.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${loc.host}/ws`;
})();
let ws = null;
let currentState = {};
let subscribers = [];
let reconnectTimer = null;
let connectAttempts = 0;
// ---- WebSocket connection ----
function connect() {
if (ws && (ws.readyState === 0 || ws.readyState === 1)) return;
ws = new WebSocket(WS_URL);
ws.onopen = () => {
console.log('🟢 WS connected');
connectAttempts = 0;
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'STATE_UPDATE' && msg.state) {
currentState = msg.state;
notifySubscribers();
}
} catch (err) {
console.error('❌ WS message error:', err);
}
};
ws.onclose = () => {
console.log('🔴 WS disconnected, reconnecting...');
scheduleReconnect();
};
ws.onerror = (err) => {
console.error('❌ WS error:', err);
ws.close();
};
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
const delay = Math.min(1000 * 2 ** connectAttempts, 10000);
connectAttempts++;
reconnectTimer = setTimeout(connect, delay);
}
// ---- Subscribe to state updates ----
/**
* Si iscrive agli aggiornamenti di stato.
* @param {(state: object) => void} callback
* @returns {() => void} unsubscribe function
*/
function onStateUpdate(callback) {
subscribers.push(callback);
// Chiamata immediata con stato corrente (se già presente)
if (Object.keys(currentState).length > 0) {
callback({ ...currentState });
}
return () => {
subscribers = subscribers.filter((cb) => cb !== callback);
};
}
function notifySubscribers() {
const state = { ...currentState };
for (const cb of subscribers) {
try { cb(state); } catch (e) { console.error(e); }
}
}
/**
* Invia un comando al server (ADD_POINT, RESET_GAME, RESET_MATCH, SET_DEUCE, SET_SERVER)
*/
function sendCommand(command, player = null) {
const msg = { type: 'COMMAND', command };
if (player !== null) msg.player = player;
sendMessage(msg);
}
/**
* Invia un aggiornamento parziale di stato al server
*/
function sendStateUpdate(partial) {
sendMessage({ type: 'STATE_UPDATE', state: partial });
}
function sendMessage(msg) {
if (ws && ws.readyState === 1) {
ws.send(JSON.stringify(msg));
} else {
console.warn('⚠️ WS not connected, cannot send');
}
}
/**
* Utility: mappa punti → label tennis
*/
function pointLabel(state) {
// Tiebreak: mostra punti numerici
if (state.tiebreak) {
return [String(state.tiebreakPoints[0]), String(state.tiebreakPoints[1])];
}
const p1 = state.points[0];
const p2 = state.points[1];
if (state.deuce) {
if (state.advantage === 1) return ['AD', '40'];
if (state.advantage === 2) return ['40', 'AD'];
return ['40', '40'];
}
const map = ['0', '15', '30', '40'];
return [map[p1] || '40', map[p2] || '40'];
}
// ---- Auto-connect ----
connect();
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Match Info</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: transparent;
color: #fff;
user-select: none;
}
.widget {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.match-info {
background: linear-gradient(135deg, rgba(0,0,0,0.85), rgba(20,20,30,0.85));
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: calc(min(12px, 0.8vw));
padding: 0.6em 1.6em;
display: inline-flex;
align-items: center;
gap: 1em;
font-size: clamp(12px, 1.4vw, 20px);
letter-spacing: 0.5px;
white-space: nowrap;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.match-info .tournament {
font-weight: 700;
color: #facc15;
text-transform: uppercase;
}
.match-info .separator {
width: 1px;
height: 1.2em;
background: rgba(255,255,255,0.2);
flex-shrink: 0;
}
.match-info .round {
font-weight: 400;
color: rgba(255,255,255,0.7);
}
.match-info .status {
font-weight: 600;
color: rgba(255,255,255,0.9);
}
.match-info .status.winner {
color: #4ade80;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.match-info { animation: fadeInUp 0.5s ease-out; }
</style>
</head>
<body>
<div class="widget">
<div class="match-info">
<span class="tournament" id="tournament">🎾 Wimbledon</span>
<span class="separator"></span>
<span class="round" id="round">Finale · Uomini · Singolare</span>
<span class="separator" id="statusSep"></span>
<span class="status" id="status">🎾 In corso · Set 1</span>
</div>
</div>
<script src="common.js"></script>
<script>
function updateMatchInfo(state) {
document.getElementById('tournament').textContent = state.tournament || '🎾 Torneo';
document.getElementById('round').textContent = state.round || '';
const statusEl = document.getElementById('status');
if (state.matchOver) {
const winner = state.sets.reduce((a, s) => a + (s[0] > s[1] ? 1 : 0), 0) >= 2
? state.player1 : state.player2;
statusEl.textContent = '🏆 ' + winner + ' vince!';
statusEl.classList.add('winner');
document.getElementById('statusSep').style.display = '';
} else {
statusEl.textContent = state.matchStatus || '🎾 In corso · Set ' + (state.currentSet + 1);
statusEl.classList.remove('winner');
document.getElementById('statusSep').style.display = '';
}
}
const unsub = onStateUpdate(updateMatchInfo);
</script>
</body>
</html>
+254
View File
@@ -0,0 +1,254 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scoreboard</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: transparent;
color: #fff;
user-select: none;
}
/* Contenitore responsive centra il widget */
.widget {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* Scoreboard si adatta al contenuto */
.scoreboard {
display: inline-block;
min-width: 380px;
max-width: 100%;
background: linear-gradient(160deg, rgba(0,0,0,0.88), rgba(10,10,20,0.88));
backdrop-filter: blur(10px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: calc(min(16px, 1.2vw));
overflow: hidden;
box-shadow: 0 8px 40px rgba(0,0,0,0.6);
}
/* Header (Sets) */
.sb-header {
display: grid;
grid-template-columns: minmax(0, 1fr) 48px 48px 48px 60px;
background: rgba(255,255,255,0.06);
border-bottom: 1px solid rgba(255,255,255,0.06);
padding: 0.5em 1.2em;
font-size: clamp(10px, 1.2vw, 14px);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: rgba(255,255,255,0.5);
}
.sb-header .col-player { text-align: left; }
.sb-header .col-set,
.sb-header .col-points { text-align: center; }
/* Player Row */
.player-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 48px 48px 48px 60px;
align-items: center;
padding: 0.6em 1.2em;
border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background 0.3s;
position: relative;
font-size: clamp(14px, 1.8vw, 22px);
}
.player-row:last-child { border-bottom: none; }
.player-row.serving {
background: rgba(250, 204, 21, 0.06);
}
.player-row.serving::before {
content: '●';
position: absolute;
left: 0.3em;
top: 50%;
transform: translateY(-50%);
font-size: 0.5em;
color: #facc15;
text-shadow: 0 0 8px rgba(250,204,21,0.6);
}
.player-row .player-name {
font-weight: 700;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5em;
}
.player-row .player-name .flag {
display: inline-block;
width: 1.6em;
height: 1.1em;
border-radius: 2px;
background-size: cover;
background-position: center;
flex-shrink: 0;
}
/* Bandiere inline SVG base64 */
.flag-it { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="1" height="2" fill="%23009246"/><rect x="1" width="1" height="2" fill="%23fff"/><rect x="2" width="1" height="2" fill="%23ce2b37"/></svg>'); }
.flag-es { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23c60b1e"/><rect y="0.5" width="3" height="1" fill="%23ffc400"/></svg>'); }
.flag-us { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23fff"/><rect width="3" height="1" fill="%23b22234"/><rect y="1" width="3" height="1" fill="%233b3b6d"/></svg>'); }
.flag-fr { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="1" height="2" fill="%23005240"/><rect x="1" width="1" height="2" fill="%23fff"/><rect x="2" width="1" height="2" fill="%23ce2b37"/></svg>'); }
.flag-gb { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23012369"/><rect width="3" height="1" fill="%23c8102e"/><rect y="0.33" width="3" height="0.33" fill="%23fff"/><rect y="1.33" width="3" height="0.33" fill="%23fff"/></svg>'); }
.flag-au { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%2300008b"/><rect width="1.5" height="1" fill="%23fff"/><path d="M0 0L1.5 1M0 1L1.5 0" stroke="%23c8102e" stroke-width="0.15"/></svg>'); }
.flag-de { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="0.67" fill="%23000"/><rect y="0.67" width="3" height="0.67" fill="%23dd0000"/><rect y="1.33" width="3" height="0.67" fill="%23ffce00"/></svg>'); }
.flag-ch { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23d52b1e"/><rect x="1.2" y="0.4" width="0.6" height="1.2" fill="%23fff"/><rect x="0.8" y="0.8" width="1.4" height="0.4" fill="%23fff"/></svg>'); }
.flag-rs { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23fff"/><rect y="0.67" width="3" height="0.67" fill="%23005240"/><rect y="1.33" width="3" height="0.67" fill="%23c8102e"/></svg>'); }
.flag-gr { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23005b94"/><rect x="0.5" y="0.5" width="1" height="1" fill="%23fff"/><rect y="0.5" width="3" height="0.2" fill="%23fff"/></svg>'); }
.flag-br { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23009246"/><circle cx="1.5" cy="1" r="0.6" fill="%23ffcc00"/><path d="M1.5 0.6l0.3 0.5h-0.6z" fill="%23005240"/></svg>'); }
.flag-ar { background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 3 2"><rect width="3" height="2" fill="%23fff"/><rect y="0.33" width="3" height="0.67" fill="%2375aadb"/><rect y="1" width="3" height="0.67" fill="%2375aadb"/></svg>'); }
.player-row .set-score {
text-align: center;
font-weight: 700;
}
.player-row .set-score.current-set {
color: #facc15;
}
.player-row .points {
text-align: center;
font-weight: 800;
letter-spacing: 1px;
font-size: 1.2em;
}
.player-row .points.deuce {
font-size: 0.7em;
font-weight: 600;
text-transform: uppercase;
color: #facc15;
}
.player-row .points.advantage {
color: #4ade80;
}
/* Animazioni */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.scoreboard { animation: fadeInUp 0.5s ease-out; }
@keyframes pointFlash {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.25); }
}
.points.flash {
animation: pointFlash 0.25s ease;
}
</style>
</head>
<body>
<div class="widget">
<div class="scoreboard" id="scoreboard">
<div class="sb-header">
<span class="col-player">Giocatore</span>
<span class="col-set">S1</span>
<span class="col-set">S2</span>
<span class="col-set">S3</span>
<span class="col-points">Punti</span>
</div>
<div class="player-row" id="player1Row">
<div class="player-name">
<span class="flag" id="flag1"></span>
<span id="player1Name">J. Sinner</span>
</div>
<span class="set-score" id="p1s1">0</span>
<span class="set-score" id="p1s2">0</span>
<span class="set-score" id="p1s3">0</span>
<span class="points" id="p1points">0</span>
</div>
<div class="player-row" id="player2Row">
<div class="player-name">
<span class="flag" id="flag2"></span>
<span id="player2Name">C. Alcaraz</span>
</div>
<span class="set-score" id="p2s1">0</span>
<span class="set-score" id="p2s2">0</span>
<span class="set-score" id="p2s3">0</span>
<span class="points" id="p2points">0</span>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
let prevPoints = [0, 0];
let prevDeuce = false;
let prevAdv = 0;
function updateScoreboard(state) {
// Nomi e bandiere
document.getElementById('player1Name').textContent = state.player1;
document.getElementById('player2Name').textContent = state.player2;
document.getElementById('flag1').className = 'flag flag-' + (state.flag1 || 'it');
document.getElementById('flag2').className = 'flag flag-' + (state.flag2 || 'es');
// Set
for (let s = 0; s < 3; s++) {
const e1 = document.getElementById('p1s' + (s + 1));
const e2 = document.getElementById('p2s' + (s + 1));
if (state.sets[s]) {
e1.textContent = state.sets[s][0] ?? '0';
e2.textContent = state.sets[s][1] ?? '0';
} else {
e1.textContent = '0';
e2.textContent = '0';
}
e1.classList.toggle('current-set', s === state.currentSet);
e2.classList.toggle('current-set', s === state.currentSet);
}
// Punti
const labels = pointLabel(state);
const p1el = document.getElementById('p1points');
const p2el = document.getElementById('p2points');
p1el.textContent = labels[0];
p2el.textContent = labels[1];
// Classi punti
p1el.className = 'points';
p2el.className = 'points';
if (state.deuce) {
p1el.classList.add('deuce');
p2el.classList.add('deuce');
if (state.advantage === 1) p1el.classList.add('advantage');
if (state.advantage === 2) p2el.classList.add('advantage');
}
// Flash su cambio punti
const pNow = state.points;
if (pNow[0] !== prevPoints[0] || pNow[1] !== prevPoints[1] ||
state.deuce !== prevDeuce || state.advantage !== prevAdv) {
const changed = pNow[0] !== prevPoints[0] ? 1 : 2;
const el = document.getElementById('p' + changed + 'points');
el.classList.remove('flash');
void el.offsetWidth;
el.classList.add('flash');
}
prevPoints = [...pNow];
prevDeuce = state.deuce;
prevAdv = state.advantage;
// Servizio
document.getElementById('player1Row').classList.toggle('serving', state.server === 1);
document.getElementById('player2Row').classList.toggle('serving', state.server === 2);
}
// Ascolta aggiornamenti
const unsub = onStateUpdate(updateScoreboard);
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Server Indicator</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
background: transparent;
color: #fff;
user-select: none;
}
.widget {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.server-indicator {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.1);
border-radius: calc(min(10px, 0.7vw));
padding: 0.5em 1.2em;
display: inline-flex;
align-items: center;
gap: 0.6em;
font-size: clamp(12px, 1.4vw, 20px);
font-weight: 600;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.server-indicator .dot {
display: inline-block;
width: 0.6em;
height: 0.6em;
border-radius: 50%;
background: #facc15;
box-shadow: 0 0 10px rgba(250,204,21,0.6);
animation: pulse 1.5s ease-in-out infinite;
}
.server-indicator .player-label {
color: rgba(255,255,255,0.8);
}
.server-indicator .player-name {
color: #fff;
font-weight: 700;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.9); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.server-indicator { animation: fadeIn 0.5s ease-out; }
</style>
</head>
<body>
<div class="widget">
<div class="server-indicator" id="indicator">
<span class="dot"></span>
<span class="player-label">Serve:</span>
<span class="player-name" id="serverName">J. Sinner</span>
</div>
</div>
<script src="common.js"></script>
<script>
function updateServer(state) {
const name = state.server === 1 ? state.player1 : state.player2;
document.getElementById('serverName').textContent = name;
}
const unsub = onStateUpdate(updateServer);
</script>
</body>
</html>