Compare commits

5 Commits

Author SHA1 Message Date
davide 7f52d81bbf chore: smetti di tracciare dev-dist (build artifact PWA, già in .gitignore)
I file del service worker generati da vite-plugin-pwa in modalità dev erano
committati nonostante dev-dist sia nel .gitignore. Vengono rigenerati a ogni
`npm run dev`, quindi non vanno versionati.
2026-06-21 18:37:36 +02:00
davide 42aa84450b chore: allinea la versione di package-lock a 2.0.0 2026-06-21 18:35:48 +02:00
davide f65f87722c docs: documenta la modalità time out e dettaglia tutte le funzioni
- README: sezione Funzionalità riscritta per spiegare in modo completo
  display e controller (layout, punteggio/set, config, cambi, servizio,
  voce, time out/spot, reset/referto, regole partita e amichevole).
- CLAUDE.md: descrizione di toggleTimeout, flag `timeout`, cartella `spot/`,
  endpoint `/spot/list` e nota sull'autoplay con audio in kiosk.
2026-06-21 18:35:34 +02:00
davide 68a5f9317e test(timeout): copri toggleTimeout e listSpotVideos
- gameState: stato iniziale `timeout: false` e ciclo toggleTimeout.
- nuovo spotUtils.test.js: cartella assente/vuota, filtro .mp4 ordinato,
  estensione case-insensitive.
2026-06-21 18:35:22 +02:00
davide fb4b14b282 feat(timeout): proietta spot video sul display durante il time out
Aggiunge il flag `timeout` nello stato e l'action `toggleTimeout`: dal
controller il pulsante Time Out commuta la modalità e il display copre il
tabellone con i video .mp4 della cartella `spot/` (non versionata).

- un solo video → loop; più video → sequenza alfabetica con ritorno al primo;
  cartella vuota/assente → overlay "TIME OUT".
- `src/spot-utils.js` (listSpotVideos) elenca i .mp4; endpoint `/spot/list`
  + file statici `/spot/<file>.mp4` serviti sia in prod (server.js) sia in
  dev (vite-plugin-websocket.js).
- il display avvia i video con audio e ripiega su muto se il browser blocca
  l'autoplay con suono.
- il controller resta operativo ma evidenzia la modalità con banner e
  pulsante arancione pulsante, in entrambi i layout (mobile ed esteso).
2026-06-21 18:35:12 +02:00
15 changed files with 286 additions and 3508 deletions
+1
View File
@@ -15,6 +15,7 @@ dist
dist-ssr dist-ssr
dev-dist dev-dist
.segnapunti .segnapunti
spot
*.local *.local
# Editor directories and files # Editor directories and files
+3 -1
View File
@@ -46,7 +46,9 @@ Punteggio, set e servizio si ricavano sempre dalla `striscia`: al primo punto di
`src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa. `src/referto.js` genera il referto di gara: `buildRefertoHtml(state, now)` è una funzione pura che restituisce l'HTML (testabile), mentre `generaReferto(state)` è il wrapper che apre la finestra con `window.open` e avvia la stampa.
`src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/persist.js` carica/salva lo stato su `.segnapunti/state.json`. `src/server-utils.js` ricava gli IP di rete (`getNetworkIPs`, con sorgente iniettabile) e stampa gli URL all'avvio. `src/spot-utils.js` espone `listSpotVideos(spotDir)`, che elenca i `.mp4` (ordinati) della cartella `spot/`; ritorna `[]` se la cartella manca.
**Time out / spot video** — l'action `toggleTimeout` commuta il flag booleano `state.timeout`. Quando attivo, il display mostra a tutto schermo i video `.mp4` della cartella `spot/` nella root (non versionata): uno solo va in loop, più video girano in sequenza ripartendo dal primo; se la cartella è vuota mostra un overlay "TIME OUT". La lista è esposta dall'endpoint HTTP `/spot/list` (JSON) e i file sono serviti staticamente da `/spot/<file>.mp4`, sia in produzione (`server.js`) sia in sviluppo (`vite-plugin-websocket.js`). I video partono con audio; se il browser blocca l'autoplay con suono si ripiega automaticamente su muto — per avere l'audio reale il browser del display (schermo senza input) va avviato in kiosk con autoplay consentito (es. Chrome `--autoplay-policy=no-user-gesture-required`).
`server.js` (Express) espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket); serve entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint. `server.js` (Express) espone `createApp(distDir)` (solo routing, testabile) e `startServer(port)` (HTTP + WebSocket); serve entrambe le interfacce sulla porta 3000: `/display``dist/index.html`, `/controller``dist/controller.html`. Singolo endpoint WebSocket su `/ws`. L'avvio automatico parte solo se il file è eseguito come entrypoint.
+71 -17
View File
@@ -81,34 +81,88 @@ http://<IP-del-server>:3000/controller
## Funzionalità ## Funzionalità
### Display Il sistema è composto da due interfacce complementari: il **display** è in sola lettura e mostra al pubblico lo stato della partita; il **controller** è il pannello operatore da cui parte ogni azione. Tutto ciò che l'operatore tocca sul controller si riflette istantaneamente sul display via WebSocket.
- Nomi squadre con indicatore di servizio ### Display (tabellone pubblico)
- Punteggio del set corrente (grande, leggibile da lontano)
- Contatore set vinti
- Striscia storica punti del set in corso, scorrevole verso destra
- Modalità formazioni: posizioni dei 6 giocatori in campo
- Indicatore connessione WebSocket (scompare quando connesso, rosso lampeggiante se disconnesso)
### Controller Pagina di sola visualizzazione, pensata per uno schermo grande o un proiettore. Mostra:
- **Punti** — `+1` per casa e ospite, con annullamento dell'ultimo punto - **Nomi squadre** con **indicatore di servizio** (l'icona della palla compare accanto alla squadra che sta servendo).
- **Dialog set vinto** — appare automaticamente al raggiungimento dei 25 punti (o 15 nel tie-break); permette di confermare il set o annullare l'ultimo punto - **Punteggio del set corrente** in caratteri molto grandi, leggibili da lontano.
- **Formazioni** — configura i numeri di maglia; la rotazione avviene automaticamente al cambio palla - **Contatore dei set vinti** per ciascuna squadra.
- **Cambi** — dialog `IN → OUT` con validazione - **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
- **Servizio** — cambio manuale (disponibile solo a 0-0) - **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
- **Visualizzazione** — alterna tra punteggio grande e formazioni in campo - **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
- **Striscia** — mostra/nasconde lo storico punti sul display - **Overlay time out / spot**: durante un time out copre il tabellone con i video della cartella `spot/` (vedi sotto).
- **Reset** — azzera la partita (richiede conferma) - **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
- **Referto** — a fine partita, dal dialog *PARTITA FINITA*, genera un referto di gara stampabile/PDF (punteggi per set, formazioni di partenza, andamento punto-punto). *Funzione prototipale.* - **Indicatore di connessione**: scompare quando tutto è connesso, diventa un punto rosso lampeggiante con scritta *Disconnesso* in caso di problemi.
- **Fullscreen automatico** all'apertura sui dispositivi mobili.
### Controller (pannello operatore)
Il controller adatta automaticamente il proprio layout all'orientamento del dispositivo:
- **Verticale (smartphone)** — layout compatto: anteprima punteggio toccabile in alto e griglia di pulsanti sotto.
- **Orizzontale (tablet)** — layout esteso "da regia": due grandi pannelli squadra affiancati con punteggio, campo e tasto set, più una barra di azioni in basso.
#### Gestione del punteggio
- **Punto** — tocca il punteggio/la card di una squadra per assegnarle `+1`. Il sistema gestisce automaticamente il **cambio palla** e la **rotazione** della squadra che riconquista il servizio.
- **Annulla punto** — rimuove l'ultimo punto assegnato, ripristinando anche eventuale rotazione e servizio.
- **Tasti SET** — incrementano manualmente il contatore dei set vinti di una squadra (utile per correzioni); il contatore cicla da 0.
- **Dialog automatico SET VINTO / PARTITA FINITA** — compare da solo al raggiungimento delle condizioni di vittoria (25 punti, o 15 nel set decisivo, con 2 di scarto). Da qui puoi:
- **VAI AL SET SUCCESSIVO** — chiude il set, lo registra come vinto e prepara il set successivo aprendo la configurazione delle formazioni;
- **INDIETRO** — annulla l'ultimo punto se il dialog è comparso per errore;
- a partita conclusa, **REFERTO** per generare il referto e **CHIUDI** per tornare al tabellone.
#### Configurazione partita
Dal pulsante **Config** si apre una finestra che permette di impostare:
- **Nomi delle squadre** (Home e Guest);
- **Modalità partita**: `2/3` (al meglio dei 3 set), `3/5` (al meglio dei 5 set) o `Amichevole` (senza vittoria automatica della partita);
- **Formazioni iniziali**: i numeri di maglia dei 6 giocatori in campo per ciascuna squadra, disposti per zona.
#### Cambi giocatore
Il pulsante **Cambi** apre, dopo aver scelto la squadra, un dialog con righe `IN → OUT`. Le sostituzioni vengono **validate** prima di essere applicate: i numeri devono essere cifre, il giocatore entrante non può essere già in campo, quello uscente deve essere effettivamente in formazione, e nessuno può sostituire sé stesso. Eventuali errori sono segnalati in chiaro.
#### Servizio e visualizzazione
- **Cambio Palla** — assegna manualmente il servizio; disponibile **solo a 0-0** (a inizio set).
- **Inverti** — scambia il lato delle due squadre sul display.
- **Formazioni / Punteggio** — alterna sul display la vista formazioni in campo e la vista punteggio.
- **Striscia ON/OFF** — mostra o nasconde lo storico punti del set sul display.
- **Voce** — fa annunciare al display il punteggio corrente con la sintesi vocale (es. *"15 a 12"*, *"15 pari"*, *"zero a zero"*).
#### Time Out e spot pubblicitari
Il pulsante **Time Out** attiva/disattiva la proiezione degli spot sul display. Quando la modalità è attiva il controller resta **pienamente operativo** ma è chiaramente evidenziato: compare un banner *MODALITÀ TIME OUT ATTIVA* e il pulsante diventa arancione e pulsante. Ripremendolo si esce e il display torna al punteggio.
I video da proiettare vanno messi in una cartella **`spot/`** nella root del progetto (non versionata). Comportamento:
- **un solo** video `.mp4` → riprodotto in loop continuo;
- **più** video `.mp4` → riprodotti in sequenza in ordine alfabetico, poi si riparte dal primo;
- **cartella vuota o assente** → il display mostra un overlay nero con la scritta *TIME OUT*.
> **Audio:** i video partono con audio, ma il display è in genere uno schermo senza tastiera/mouse e i browser bloccano l'autoplay con suono senza un'interazione dell'utente. In assenza di configurazione il video parte automaticamente **muto** (il video si vede comunque). Per avere l'audio, avvia il browser del display in modalità kiosk consentendo l'autoplay con suono (es. Chrome con `--autoplay-policy=no-user-gesture-required`, oppure impostando un'eccezione del sito per audio/video).
#### Reset e referto
- **Reset** — azzera la partita (punteggi, set e formazioni) previa conferma, e riapre subito la configurazione per impostare la partita successiva.
- **Referto** — a fine partita, dal dialog *PARTITA FINITA*, genera un referto di gara stampabile/PDF con i punteggi per set, le formazioni di partenza e l'andamento punto-punto. *Funzione prototipale.*
### Regole pallavolo integrate ### Regole pallavolo integrate
Le condizioni di vittoria del set sono applicate automaticamente dal server (è quello che fa comparire il dialog *SET VINTO*):
| Set | Condizione di vittoria | | Set | Condizione di vittoria |
|---|---| |---|---|
| Set 14 (modalità 3/5) o 12 (modalità 2/3) | Primo a **25** con almeno 2 punti di scarto | | Set 14 (modalità 3/5) o 12 (modalità 2/3) | Primo a **25** con almeno 2 punti di scarto |
| Set decisivo (tie-break) | Primo a **15** con almeno 2 punti di scarto | | Set decisivo (tie-break) | Primo a **15** con almeno 2 punti di scarto |
La **partita** è vinta da chi raggiunge 3 set su 5 (modalità `3/5`) o 2 set su 3 (modalità `2/3`). In modalità **Amichevole** non c'è vittoria automatica della partita: si continua a giocare set senza che compaia *PARTITA FINITA*.
--- ---
-1
View File
@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
-92
View File
@@ -1,92 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5357ef54'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"revision": null,
"url": "index.html"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
}));
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "segnapuntianto", "name": "segnapuntianto",
"version": "0.0.0", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "segnapuntianto", "name": "segnapuntianto",
"version": "0.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"express": "^5.2.1", "express": "^5.2.1",
"vue": "^3.2.47", "vue": "^3.2.47",
+9 -1
View File
@@ -6,19 +6,27 @@ import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js' import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js' import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js' import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename) const __dirname = dirname(__filename)
const DIST_DIR = join(__dirname, 'dist') const DIST_DIR = join(__dirname, 'dist')
const SPOT_DIR = join(__dirname, 'spot')
// Crea l'app Express (asset statici + route display/controller) senza avviare il // Crea l'app Express (asset statici + route display/controller) senza avviare il
// listen né il WebSocket: così il routing è testabile in isolamento. // listen né il WebSocket: così il routing è testabile in isolamento.
export function createApp(distDir = DIST_DIR) { export function createApp(distDir = DIST_DIR, spotDir = SPOT_DIR) {
const app = express() const app = express()
app.use(express.static(distDir, { index: false })) app.use(express.static(distDir, { index: false }))
// Video spot del time out: elenco JSON + file statici (range requests gestiti
// da express.static, così il seeking dei video funziona). La route /spot/list
// va prima dello static per non collidere con un eventuale file "list".
app.get('/spot/list', (_req, res) => res.json(listSpotVideos(spotDir)))
app.use('/spot', express.static(spotDir))
app.get(['/', '/display', '/display/*splat'], (_req, res) => { app.get(['/', '/display', '/display/*splat'], (_req, res) => {
res.sendFile(join(distDir, 'index.html')) res.sendFile(join(distDir, 'index.html'))
}) })
+33
View File
@@ -6,6 +6,9 @@
{{ wsConnected ? 'Connesso' : 'Connessione...' }} {{ wsConnected ? 'Connesso' : 'Connessione...' }}
</div> </div>
<!-- Banner modalità time out attiva -->
<div class="timeout-banner" v-if="state.timeout"> MODALITÀ TIME OUT ATTIVA</div>
<!-- MODALITÀ MOBILE --> <!-- MODALITÀ MOBILE -->
<template v-if="!isEstesa"> <template v-if="!isEstesa">
<!-- Anteprima punteggio --> <!-- Anteprima punteggio -->
@@ -64,6 +67,9 @@
<button class="btn btn-ctrl" @click="openCambiTeam()"> <button class="btn btn-ctrl" @click="openCambiTeam()">
Cambi Cambi
</button> </button>
<button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }" @click="sendAction({ type: 'toggleTimeout' })">
Time Out
</button>
<button class="btn btn-danger" @click="confirmReset = true"> <button class="btn btn-danger" @click="confirmReset = true">
Reset Reset
</button> </button>
@@ -142,6 +148,7 @@
<button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</button> <button class="btn e-act" @click="sendAction({ type: 'toggleStriscia' })">Striscia {{ state.visuStriscia ? 'ON' : 'OFF' }}</button>
<button class="btn e-act" @click="speak()">Voce</button> <button class="btn e-act" @click="speak()">Voce</button>
<button class="btn e-act" @click="openConfig()">Config</button> <button class="btn e-act" @click="openConfig()">Config</button>
<button class="btn e-act btn-timeout" :class="{ active: state.timeout }" @click="sendAction({ type: 'toggleTimeout' })"> Time Out</button>
<button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button> <button class="btn e-act e-act--danger" @click="confirmReset = true">Reset</button>
</div> </div>
</div> </div>
@@ -489,6 +496,32 @@ export default {
.conn-bar.connected { .conn-bar.connected {
background: #2e7d32; background: #2e7d32;
} }
/* Banner modalità time out */
.timeout-banner {
background: #e65100;
color: #fff;
text-align: center;
font-weight: 800;
letter-spacing: 0.04em;
padding: 10px;
border-radius: 10px;
margin-bottom: 8px;
animation: timeout-pulse 1.2s ease-in-out infinite;
}
/* Pulsante time out evidenziato quando attivo */
.btn-timeout.active {
background: #e65100;
color: #fff;
box-shadow: 0 0 0 2px rgba(255, 152, 0, 0.6);
animation: timeout-pulse 1.2s ease-in-out infinite;
}
@keyframes timeout-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.dot { .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
+79
View File
@@ -112,6 +112,12 @@
{{ wsConnected ? '' : 'Disconnesso' }} {{ wsConnected ? '' : 'Disconnesso' }}
</div> </div>
</div> </div>
<!-- Overlay time out: proietta i video spot a tutto schermo -->
<div v-if="state.timeout" class="timeout-overlay">
<video v-if="spotList.length" ref="spotVideo" class="timeout-video" playsinline></video>
<div v-else class="timeout-placeholder">TIME OUT</div>
</div>
</section> </section>
</template> </template>
@@ -121,6 +127,12 @@ import { createWsMixin } from '../wsMixin.js'
export default { export default {
name: "DisplayPage", name: "DisplayPage",
mixins: [createWsMixin('display')], mixins: [createWsMixin('display')],
data() {
return {
spotList: [], // filename dei video spot da riprodurre
spotIndex: 0, // video corrente nella sequenza
}
},
mounted() { mounted() {
if (this.isMobile()) { if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {} try { document.documentElement.requestFullscreen() } catch (e) {}
@@ -149,8 +161,51 @@ export default {
}) })
} }
}, },
'state.timeout': {
handler(attivo) {
attivo ? this.avviaTimeout() : this.fermaTimeout()
},
},
}, },
methods: { methods: {
async avviaTimeout() {
this.spotIndex = 0
try {
const res = await fetch('/spot/list')
this.spotList = await res.json()
} catch {
this.spotList = []
}
if (!this.spotList.length) return
await this.$nextTick()
this.caricaSpotCorrente()
},
fermaTimeout() {
const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.spotList = []
},
caricaSpotCorrente() {
const video = this.$refs.spotVideo
if (!video || !this.spotList.length) return
video.src = '/spot/' + encodeURIComponent(this.spotList[this.spotIndex])
// Avanza al prossimo video alla fine (loop: con un solo video torna su sé stesso)
video.onended = () => {
this.spotIndex = (this.spotIndex + 1) % this.spotList.length
this.caricaSpotCorrente()
}
this.playSpot()
},
playSpot() {
const video = this.$refs.spotVideo
if (!video) return
// Prova con audio; se il browser blocca l'autoplay con suono, ripiega su muto.
video.muted = false
const p = video.play()
if (p && typeof p.catch === 'function') {
p.catch(() => { video.muted = true; video.play().catch(() => {}) })
}
},
onWsMessage(msg) { onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text) if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message) else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
@@ -247,4 +302,28 @@ export default {
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
.timeout-overlay {
position: fixed;
inset: 0;
background: #000;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.timeout-video {
width: 100%;
height: 100%;
object-fit: contain;
}
.timeout-placeholder {
color: #fff;
font-size: 20vh;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
</style> </style>
+6
View File
@@ -21,6 +21,7 @@ export function createInitialState() {
order: true, order: true,
visuForm: false, visuForm: false,
visuStriscia: true, visuStriscia: true,
timeout: false,
modalitaPartita: "3/5", modalitaPartita: "3/5",
sp: { sp: {
striscia: [{ serv: 'h', ris: '', vinc: null }], striscia: [{ serv: 'h', ris: '', vinc: null }],
@@ -164,6 +165,11 @@ export function applyAction(state, action) {
break break
} }
case "toggleTimeout": {
s.timeout = !s.timeout
break
}
case "setNomi": { case "setNomi": {
if (action.home !== undefined) s.sp.nomi.home = action.home if (action.home !== undefined) s.sp.nomi.home = action.home
if (action.guest !== undefined) s.sp.nomi.guest = action.guest if (action.guest !== undefined) s.sp.nomi.guest = action.guest
+16
View File
@@ -0,0 +1,16 @@
import { readdirSync } from 'fs'
/**
* Elenca i video .mp4 presenti nella cartella spot.
* Ritorna i filename ordinati alfabeticamente (sequenza prevedibile),
* oppure un array vuoto se la cartella non esiste o non è leggibile.
*/
export function listSpotVideos(spotDir) {
try {
return readdirSync(spotDir)
.filter(name => name.toLowerCase().endsWith('.mp4'))
.sort((a, b) => a.localeCompare(b))
} catch {
return []
}
}
+12
View File
@@ -79,6 +79,10 @@ describe('Game Logic (gameState.js)', () => {
expect(state.visuForm).toBe(false) expect(state.visuForm).toBe(false)
expect(state.visuStriscia).toBe(true) expect(state.visuStriscia).toBe(true)
}) })
it('dovrebbe avere timeout false', () => {
expect(state.timeout).toBe(false)
})
}) })
// ============================================= // =============================================
@@ -479,6 +483,14 @@ describe('Game Logic (gameState.js)', () => {
const s = applyAction(state, { type: 'toggleOrder' }) const s = applyAction(state, { type: 'toggleOrder' })
expect(s.order).toBe(false) expect(s.order).toBe(false)
}) })
it('toggleTimeout: false → true → false', () => {
expect(state.timeout).toBe(false)
const s1 = applyAction(state, { type: 'toggleTimeout' })
expect(s1.timeout).toBe(true)
const s2 = applyAction(s1, { type: 'toggleTimeout' })
expect(s2.timeout).toBe(false)
})
}) })
// ============================================= // =============================================
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, writeFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { listSpotVideos } from '../../src/spot-utils.js'
describe('listSpotVideos (spot-utils.js)', () => {
let dir
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'spot-test-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('ritorna [] se la cartella non esiste', () => {
expect(listSpotVideos(join(dir, 'inesistente'))).toEqual([])
})
it('ritorna [] se la cartella è vuota', () => {
expect(listSpotVideos(dir)).toEqual([])
})
it('elenca solo i file .mp4, ordinati alfabeticamente', () => {
writeFileSync(join(dir, 'b.mp4'), '')
writeFileSync(join(dir, 'a.mp4'), '')
writeFileSync(join(dir, 'note.txt'), '')
writeFileSync(join(dir, 'foto.png'), '')
expect(listSpotVideos(dir)).toEqual(['a.mp4', 'b.mp4'])
})
it('riconosce l\'estensione .mp4 senza distinzione di maiuscole', () => {
writeFileSync(join(dir, 'SPOT.MP4'), '')
expect(listSpotVideos(dir)).toEqual(['SPOT.MP4'])
})
})
+16
View File
@@ -1,7 +1,13 @@
import { WebSocketServer } from 'ws' import { WebSocketServer } from 'ws'
import express from 'express'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import { setupWebSocketHandler } from './src/websocket-handler.js' import { setupWebSocketHandler } from './src/websocket-handler.js'
import { printServerInfo } from './src/server-utils.js' import { printServerInfo } from './src/server-utils.js'
import { loadState, saveState } from './src/persist.js' import { loadState, saveState } from './src/persist.js'
import { listSpotVideos } from './src/spot-utils.js'
const SPOT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'spot')
export default function websocketPlugin() { export default function websocketPlugin() {
return { return {
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
const wss = new WebSocketServer({ noServer: true }) const wss = new WebSocketServer({ noServer: true })
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState }) setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
// Video spot del time out: stesso comportamento di server.js (elenco + statici)
server.middlewares.use('/spot', (req, res, next) => {
if (req.url === '/list' || req.url === '/list/') {
res.setHeader('Content-Type', 'application/json')
return res.end(JSON.stringify(listSpotVideos(SPOT_DIR)))
}
next()
})
server.middlewares.use('/spot', express.static(SPOT_DIR))
// Rewrite /display → / (index.html) e /controller → /controller.html // Rewrite /display → / (index.html) e /controller → /controller.html
server.middlewares.use((req, _res, next) => { server.middlewares.use((req, _res, next) => {
if (req.url === '/display' || req.url === '/display/') req.url = '/' if (req.url === '/display' || req.url === '/display/') req.url = '/'