Compare commits
5 Commits
ffba5b368d
...
7f52d81bbf
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f52d81bbf | |||
| 42aa84450b | |||
| f65f87722c | |||
| 68a5f9317e | |||
| fb4b14b282 |
@@ -15,6 +15,7 @@ dist
|
||||
dist-ssr
|
||||
dev-dist
|
||||
.segnapunti
|
||||
spot
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -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/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.
|
||||
|
||||
|
||||
@@ -81,34 +81,88 @@ http://<IP-del-server>:3000/controller
|
||||
|
||||
## 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
|
||||
- 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)
|
||||
### Display (tabellone pubblico)
|
||||
|
||||
### 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
|
||||
- **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
|
||||
- **Formazioni** — configura i numeri di maglia; la rotazione avviene automaticamente al cambio palla
|
||||
- **Cambi** — dialog `IN → OUT` con validazione
|
||||
- **Servizio** — cambio manuale (disponibile solo a 0-0)
|
||||
- **Visualizzazione** — alterna tra punteggio grande e formazioni in campo
|
||||
- **Striscia** — mostra/nasconde lo storico punti sul display
|
||||
- **Reset** — azzera la partita (richiede conferma)
|
||||
- **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.*
|
||||
- **Nomi squadre** con **indicatore di servizio** (l'icona della palla compare accanto alla squadra che sta servendo).
|
||||
- **Punteggio del set corrente** in caratteri molto grandi, leggibili da lontano.
|
||||
- **Contatore dei set vinti** per ciascuna squadra.
|
||||
- **Striscia storica dei punti** del set in corso: una sequenza scorrevole che ricostruisce l'andamento punto su punto.
|
||||
- **Modalità formazioni**: in alternativa al punteggio, mostra le posizioni dei 6 giocatori in campo (numeri di maglia disposti come a referto).
|
||||
- **Ordine di visualizzazione invertibile**: le due squadre possono essere scambiate di lato per rispecchiare l'orientamento reale del campo.
|
||||
- **Overlay time out / spot**: durante un time out copre il tabellone con i video della cartella `spot/` (vedi sotto).
|
||||
- **Annuncio vocale del punteggio** (sintesi vocale del browser), comandato dal controller.
|
||||
- **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
|
||||
|
||||
Le condizioni di vittoria del set sono applicate automaticamente dal server (è quello che fa comparire il dialog *SET VINTO*):
|
||||
|
||||
| Set | Condizione di vittoria |
|
||||
|---|---|
|
||||
| Set 1–4 (modalità 3/5) o 1–2 (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 |
|
||||
|
||||
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 +0,0 @@
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||
@@ -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} didn’t 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
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "segnapuntianto",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "segnapuntianto",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.0",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1",
|
||||
"vue": "^3.2.47",
|
||||
|
||||
@@ -6,19 +6,27 @@ import { dirname, join } from 'path'
|
||||
import { setupWebSocketHandler } from './src/websocket-handler.js'
|
||||
import { printServerInfo } from './src/server-utils.js'
|
||||
import { loadState, saveState } from './src/persist.js'
|
||||
import { listSpotVideos } from './src/spot-utils.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const DIST_DIR = join(__dirname, 'dist')
|
||||
const SPOT_DIR = join(__dirname, 'spot')
|
||||
|
||||
// Crea l'app Express (asset statici + route display/controller) senza avviare il
|
||||
// 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()
|
||||
|
||||
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) => {
|
||||
res.sendFile(join(distDir, 'index.html'))
|
||||
})
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
|
||||
</div>
|
||||
|
||||
<!-- Banner modalità time out attiva -->
|
||||
<div class="timeout-banner" v-if="state.timeout">⏱ MODALITÀ TIME OUT ATTIVA</div>
|
||||
|
||||
<!-- ══════════════════ MODALITÀ MOBILE ══════════════════ -->
|
||||
<template v-if="!isEstesa">
|
||||
<!-- Anteprima punteggio -->
|
||||
@@ -64,6 +67,9 @@
|
||||
<button class="btn btn-ctrl" @click="openCambiTeam()">
|
||||
Cambi
|
||||
</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">
|
||||
Reset
|
||||
</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="speak()">Voce</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -489,6 +496,32 @@ export default {
|
||||
.conn-bar.connected {
|
||||
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 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@@ -112,6 +112,12 @@
|
||||
{{ wsConnected ? '' : 'Disconnesso' }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -121,6 +127,12 @@ import { createWsMixin } from '../wsMixin.js'
|
||||
export default {
|
||||
name: "DisplayPage",
|
||||
mixins: [createWsMixin('display')],
|
||||
data() {
|
||||
return {
|
||||
spotList: [], // filename dei video spot da riprodurre
|
||||
spotIndex: 0, // video corrente nella sequenza
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.isMobile()) {
|
||||
try { document.documentElement.requestFullscreen() } catch (e) {}
|
||||
@@ -149,8 +161,51 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
'state.timeout': {
|
||||
handler(attivo) {
|
||||
attivo ? this.avviaTimeout() : this.fermaTimeout()
|
||||
},
|
||||
},
|
||||
},
|
||||
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) {
|
||||
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
|
||||
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
|
||||
@@ -247,4 +302,28 @@ export default {
|
||||
overflow: hidden;
|
||||
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>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createInitialState() {
|
||||
order: true,
|
||||
visuForm: false,
|
||||
visuStriscia: true,
|
||||
timeout: false,
|
||||
modalitaPartita: "3/5",
|
||||
sp: {
|
||||
striscia: [{ serv: 'h', ris: '', vinc: null }],
|
||||
@@ -164,6 +165,11 @@ export function applyAction(state, action) {
|
||||
break
|
||||
}
|
||||
|
||||
case "toggleTimeout": {
|
||||
s.timeout = !s.timeout
|
||||
break
|
||||
}
|
||||
|
||||
case "setNomi": {
|
||||
if (action.home !== undefined) s.sp.nomi.home = action.home
|
||||
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,10 @@ describe('Game Logic (gameState.js)', () => {
|
||||
expect(state.visuForm).toBe(false)
|
||||
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' })
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,13 @@
|
||||
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 { printServerInfo } from './src/server-utils.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() {
|
||||
return {
|
||||
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
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
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (req.url === '/display' || req.url === '/display/') req.url = '/'
|
||||
|
||||
Reference in New Issue
Block a user