feat(display): countdown, fallback su immagine e dissolvenza nell'overlay time out

L'overlay ora riproduce il video scelto dal controller mostrando un
countdown 00:30→00:00 calcolato dallo stato condiviso. Se il video finisce
prima dei 30s subentra un'immagine "TIME OUT + nome squadra" fino allo
scadere; se dura più di 30s si applica una dissolvenza CSS negli ultimi
istanti. In caso di riconnessione a metà time out il video viene
riallineato (currentTime) al punto corretto.
This commit is contained in:
2026-07-03 10:14:15 +02:00
parent e179295af3
commit c4599a2722
+79 -21
View File
@@ -113,10 +113,14 @@
</div>
</div>
<!-- Overlay time out: proietta i video spot a tutto schermo -->
<!-- Overlay time out: proietta il 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 class="timeout-countdown">{{ timeoutCountdown }}</div>
<video v-if="state.timeoutVideo && !mostraPlaceholder" ref="spotVideo" class="timeout-video"
:class="{ 'timeout-video--fading': timeoutFading }" playsinline></video>
<div v-else class="timeout-placeholder">
TIME OUT<br />{{ state.sp.nomi[state.timeoutTeam] }}
</div>
</div>
</section>
</template>
@@ -129,16 +133,38 @@ export default {
mixins: [createWsMixin('display')],
data() {
return {
spotList: [], // filename dei video spot da riprodurre
spotIndex: 0, // video corrente nella sequenza
mostraPlaceholder: false, // video terminato prima dei 30s: mostra l'immagine "TIME OUT"
spotVideoDuration: null, // durata reale del video in riproduzione, nota dopo il loadedmetadata
nowTick: Date.now(),
timeoutTicker: null,
}
},
mounted() {
if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {}
}
if (this.state.timeout) this.avviaTimeout()
},
beforeUnmount() {
this.fermaTimeoutTicker()
},
computed: {
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}`
},
timeoutFading() {
// Video più lungo di 30s: dissolvenza negli ultimi istanti prima dello stop del server.
if (!this.spotVideoDuration || this.spotVideoDuration <= 30) return false
return this.timeoutRemaining <= 1
},
stricciaStrip() {
const currentSet = this.state.sp.striscia.at(-1)
if (!currentSet) return { home: [], guest: [] }
@@ -168,32 +194,41 @@ export default {
},
},
methods: {
avviaTimeoutTicker() {
this.fermaTimeoutTicker()
this.nowTick = Date.now()
this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 200)
},
fermaTimeoutTicker() {
if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null }
},
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
this.mostraPlaceholder = !this.state.timeoutVideo
this.spotVideoDuration = null
this.avviaTimeoutTicker()
if (!this.state.timeoutVideo) return
await this.$nextTick()
this.caricaSpotCorrente()
},
fermaTimeout() {
this.fermaTimeoutTicker()
const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.spotList = []
this.mostraPlaceholder = false
this.spotVideoDuration = null
},
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()
if (!video || !this.state.timeoutVideo) return
video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
video.onloadedmetadata = () => {
this.spotVideoDuration = video.duration
// Riconnessione a metà time out: riallinea il video al punto corretto.
const trascorsi = this.state.timeoutStartedAt ? (Date.now() - this.state.timeoutStartedAt) / 1000 : 0
if (trascorsi > 0.5 && trascorsi < video.duration) video.currentTime = trascorsi
}
// Video più corto dei 30s: a fine riproduzione mostra l'immagine "TIME OUT" fino allo stop del server.
video.onended = () => { this.mostraPlaceholder = true }
this.playSpot()
},
playSpot() {
@@ -320,14 +355,37 @@ export default {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.8s ease-out;
}
.timeout-video--fading {
opacity: 0;
}
.timeout-countdown {
position: absolute;
top: 24px;
left: 50%;
transform: translateX(-50%);
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-size: 4vh;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.05em;
padding: 6px 18px;
border-radius: 12px;
z-index: 501;
}
.timeout-placeholder {
color: #ff9800;
font-size: 20vh;
font-size: 14vh;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
text-align: center;
line-height: 1.3;
animation: timeout-pulse 1.2s ease-in-out infinite;
}