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