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).
This commit is contained in:
2026-06-21 18:35:08 +02:00
parent ffba5b368d
commit fb4b14b282
7 changed files with 160 additions and 1 deletions
+33
View File
@@ -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;
+79
View File
@@ -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>
+6
View File
@@ -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
+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 []
}
}