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>
</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"> <div v-if="state.timeout" class="timeout-overlay">
<video v-if="spotList.length" ref="spotVideo" class="timeout-video" playsinline></video> <div class="timeout-countdown">{{ timeoutCountdown }}</div>
<div v-else class="timeout-placeholder">TIME OUT</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> </div>
</section> </section>
</template> </template>
@@ -129,16 +133,38 @@ export default {
mixins: [createWsMixin('display')], mixins: [createWsMixin('display')],
data() { data() {
return { return {
spotList: [], // filename dei video spot da riprodurre mostraPlaceholder: false, // video terminato prima dei 30s: mostra l'immagine "TIME OUT"
spotIndex: 0, // video corrente nella sequenza spotVideoDuration: null, // durata reale del video in riproduzione, nota dopo il loadedmetadata
nowTick: Date.now(),
timeoutTicker: null,
} }
}, },
mounted() { mounted() {
if (this.isMobile()) { if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {} try { document.documentElement.requestFullscreen() } catch (e) {}
} }
if (this.state.timeout) this.avviaTimeout()
},
beforeUnmount() {
this.fermaTimeoutTicker()
}, },
computed: { 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() { stricciaStrip() {
const currentSet = this.state.sp.striscia.at(-1) const currentSet = this.state.sp.striscia.at(-1)
if (!currentSet) return { home: [], guest: [] } if (!currentSet) return { home: [], guest: [] }
@@ -168,32 +194,41 @@ export default {
}, },
}, },
methods: { 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() { async avviaTimeout() {
this.spotIndex = 0 this.mostraPlaceholder = !this.state.timeoutVideo
try { this.spotVideoDuration = null
const res = await fetch('/spot/list') this.avviaTimeoutTicker()
this.spotList = await res.json() if (!this.state.timeoutVideo) return
} catch {
this.spotList = []
}
if (!this.spotList.length) return
await this.$nextTick() await this.$nextTick()
this.caricaSpotCorrente() this.caricaSpotCorrente()
}, },
fermaTimeout() { fermaTimeout() {
this.fermaTimeoutTicker()
const video = this.$refs.spotVideo const video = this.$refs.spotVideo
if (video) { video.pause(); video.removeAttribute('src'); video.load() } if (video) { video.pause(); video.removeAttribute('src'); video.load() }
this.spotList = [] this.mostraPlaceholder = false
this.spotVideoDuration = null
}, },
caricaSpotCorrente() { caricaSpotCorrente() {
const video = this.$refs.spotVideo const video = this.$refs.spotVideo
if (!video || !this.spotList.length) return if (!video || !this.state.timeoutVideo) return
video.src = '/spot/' + encodeURIComponent(this.spotList[this.spotIndex]) video.src = '/spot/' + encodeURIComponent(this.state.timeoutVideo)
// Avanza al prossimo video alla fine (loop: con un solo video torna su sé stesso) video.onloadedmetadata = () => {
video.onended = () => { this.spotVideoDuration = video.duration
this.spotIndex = (this.spotIndex + 1) % this.spotList.length // Riconnessione a metà time out: riallinea il video al punto corretto.
this.caricaSpotCorrente() 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() this.playSpot()
}, },
playSpot() { playSpot() {
@@ -320,14 +355,37 @@ export default {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; 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 { .timeout-placeholder {
color: #ff9800; color: #ff9800;
font-size: 20vh; font-size: 14vh;
font-weight: 800; font-weight: 800;
letter-spacing: 0.05em; letter-spacing: 0.05em;
text-transform: uppercase; text-transform: uppercase;
text-align: center;
line-height: 1.3;
animation: timeout-pulse 1.2s ease-in-out infinite; animation: timeout-pulse 1.2s ease-in-out infinite;
} }