feat(controller): modal di scelta squadra e video per il time out

Il bottone Time Out apre un modal invece di attivare direttamente
l'overlay: si sceglie la squadra che lo ha richiesto e uno dei video
in spot/ (durata rilevata lato client via <video preload=metadata>).
A time out attivo il bottone diventa "Termina" con countdown, per
poterlo interrompere manualmente prima dei 30s.
This commit is contained in:
2026-07-03 10:14:03 +02:00
parent d11b47c809
commit e179295af3
+144 -4
View File
@@ -7,7 +7,9 @@
</div> </div>
<!-- Banner modalità time out attiva --> <!-- Banner modalità time out attiva -->
<div class="timeout-banner" v-if="state.timeout"> MODALITÀ TIME OUT ATTIVA</div> <div class="timeout-banner" v-if="state.timeout">
TIME OUT {{ state.sp.nomi[state.timeoutTeam] }} {{ timeoutCountdown }}
</div>
<!-- MODALITÀ MOBILE --> <!-- MODALITÀ MOBILE -->
<template v-if="!isEstesa"> <template v-if="!isEstesa">
@@ -67,8 +69,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' })"> <button class="btn btn-ctrl btn-timeout" :class="{ active: state.timeout }"
Time Out @click="state.timeout ? terminaTimeout() : openTimeout()">
{{ state.timeout ? ' ' + timeoutCountdown : ' Time Out' }}
</button> </button>
<button class="btn btn-danger" @click="confirmReset = true"> <button class="btn btn-danger" @click="confirmReset = true">
Reset Reset
@@ -148,7 +151,8 @@
<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 btn-timeout" :class="{ active: state.timeout }"
@click="state.timeout ? terminaTimeout() : openTimeout()">{{ state.timeout ? ' ' + timeoutCountdown : ' 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>
@@ -277,6 +281,43 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Finestra avvio time out: squadra + scelta video -->
<div class="overlay" v-if="showTimeoutModal" @click.self="showTimeoutModal = false">
<div class="dialog dialog-timeout">
<div class="dialog-title">Time Out</div>
<div class="form-group">
<label>Squadra che lo ha richiesto</label>
<div class="dialog-buttons">
<button class="btn btn-set home-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'home' }"
@click="timeoutTeamSel = 'home'">{{ state.sp.nomi.home }}</button>
<button class="btn btn-set guest-bg" :class="{ 'team-sel-active': timeoutTeamSel === 'guest' }"
@click="timeoutTeamSel = 'guest'">{{ state.sp.nomi.guest }}</button>
</div>
</div>
<div class="form-group">
<label>Video (30s nominali)</label>
<div class="timeout-video-list">
<label class="timeout-video-option" v-for="file in spotList" :key="file">
<input type="radio" name="timeoutVideo" :value="file" v-model="timeoutVideoSel" />
{{ file }}
<span class="video-duration">{{ formatDurata(spotDurations[file]) }}</span>
</label>
<label class="timeout-video-option">
<input type="radio" name="timeoutVideo" :value="null" v-model="timeoutVideoSel" />
Nessun video (overlay "TIME OUT")
</label>
</div>
</div>
<div class="dialog-buttons">
<button class="btn btn-cancel" @click="showTimeoutModal = false">Annulla</button>
<button class="btn btn-confirm" :disabled="!timeoutTeamSel" @click="avviaTimeout()">Avvia</button>
</div>
</div>
</div>
</section> </section>
</template> </template>
@@ -304,6 +345,13 @@ export default {
formGuest: ["1", "2", "3", "4", "5", "6"], formGuest: ["1", "2", "3", "4", "5", "6"],
}, },
isLandscape: window.innerWidth > window.innerHeight, isLandscape: window.innerWidth > window.innerHeight,
showTimeoutModal: false,
timeoutTeamSel: null,
timeoutVideoSel: null,
spotList: [],
spotDurations: {},
nowTick: Date.now(),
timeoutTicker: null,
} }
}, },
computed: { computed: {
@@ -338,6 +386,17 @@ export default {
} }
return hasComplete return hasComplete
}, },
timeoutRemaining() {
if (!this.state.timeout || !this.state.timeoutStartedAt) return 30
const trascorsi = (this.nowTick - this.state.timeoutStartedAt) / 1000
return Math.max(0, Math.ceil(30 - trascorsi))
},
timeoutCountdown() {
const r = this.timeoutRemaining
const m = String(Math.floor(r / 60)).padStart(2, '0')
const s = String(r % 60).padStart(2, '0')
return `${m}:${s}`
},
}, },
watch: { watch: {
squadraVincente(val) { squadraVincente(val) {
@@ -346,6 +405,12 @@ export default {
this.showSetVinto = true this.showSetVinto = true
} }
}, },
'state.timeout': {
immediate: true,
handler(attivo) {
attivo ? this.avviaTimeoutTicker() : this.fermaTimeoutTicker()
},
},
}, },
mounted() { mounted() {
this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight } this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight }
@@ -355,6 +420,7 @@ export default {
beforeUnmount() { beforeUnmount() {
window.removeEventListener('resize', this._resizeHandler) window.removeEventListener('resize', this._resizeHandler)
window.removeEventListener('orientationchange', this._resizeHandler) window.removeEventListener('orientationchange', this._resizeHandler)
this.fermaTimeoutTicker()
}, },
methods: { methods: {
generaReferto, generaReferto,
@@ -458,6 +524,47 @@ export default {
: this.servHome ? `${home} a ${guest}` : `${guest} a ${home}` : this.servHome ? `${home} a ${guest}` : `${guest} a ${home}`
this.sendWs({ type: 'speak', text }) this.sendWs({ type: 'speak', text })
}, },
avviaTimeoutTicker() {
this.fermaTimeoutTicker()
this.nowTick = Date.now()
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 250)
},
fermaTimeoutTicker() {
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
},
async openTimeout() {
this.timeoutTeamSel = null
this.timeoutVideoSel = null
this.spotList = []
this.spotDurations = {}
this.showTimeoutModal = true
try {
const res = await fetch('/spot/list')
this.spotList = await res.json()
} catch {
this.spotList = []
}
this.spotList.forEach(file => this.probeDurata(file))
},
probeDurata(file) {
const video = document.createElement('video')
video.preload = 'metadata'
video.onloadedmetadata = () => {
this.spotDurations = { ...this.spotDurations, [file]: video.duration }
}
video.src = '/spot/' + encodeURIComponent(file)
},
formatDurata(sec) {
return typeof sec === 'number' && isFinite(sec) ? `${Math.round(sec)}s` : '…'
},
avviaTimeout() {
if (!this.timeoutTeamSel) return
this.sendAction({ type: 'startTimeout', team: this.timeoutTeamSel, video: this.timeoutVideoSel })
this.showTimeoutModal = false
},
terminaTimeout() {
this.sendAction({ type: 'stopTimeout' })
},
}, },
} }
</script> </script>
@@ -1070,4 +1177,37 @@ export default {
padding: 8px 0; padding: 8px 0;
font-weight: 600; font-weight: 600;
} }
/* Modal avvio time out */
.dialog-timeout {
max-height: 85vh;
overflow-y: auto;
}
.btn-set.team-sel-active {
box-shadow: 0 0 0 3px rgba(255,255,255,0.6) inset;
}
.timeout-video-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 40vh;
overflow-y: auto;
}
.timeout-video-option {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
cursor: pointer;
}
.video-duration {
margin-left: auto;
color: #aaa;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
</style> </style>