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:
@@ -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 []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user