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:
@@ -15,6 +15,7 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
dev-dist
|
dev-dist
|
||||||
.segnapunti
|
.segnapunti
|
||||||
|
spot
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
|
|||||||
@@ -6,19 +6,27 @@ import { dirname, join } from 'path'
|
|||||||
import { setupWebSocketHandler } from './src/websocket-handler.js'
|
import { setupWebSocketHandler } from './src/websocket-handler.js'
|
||||||
import { printServerInfo } from './src/server-utils.js'
|
import { printServerInfo } from './src/server-utils.js'
|
||||||
import { loadState, saveState } from './src/persist.js'
|
import { loadState, saveState } from './src/persist.js'
|
||||||
|
import { listSpotVideos } from './src/spot-utils.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
const DIST_DIR = join(__dirname, 'dist')
|
const DIST_DIR = join(__dirname, 'dist')
|
||||||
|
const SPOT_DIR = join(__dirname, 'spot')
|
||||||
|
|
||||||
// Crea l'app Express (asset statici + route display/controller) senza avviare il
|
// Crea l'app Express (asset statici + route display/controller) senza avviare il
|
||||||
// listen né il WebSocket: così il routing è testabile in isolamento.
|
// listen né il WebSocket: così il routing è testabile in isolamento.
|
||||||
export function createApp(distDir = DIST_DIR) {
|
export function createApp(distDir = DIST_DIR, spotDir = SPOT_DIR) {
|
||||||
const app = express()
|
const app = express()
|
||||||
|
|
||||||
app.use(express.static(distDir, { index: false }))
|
app.use(express.static(distDir, { index: false }))
|
||||||
|
|
||||||
|
// Video spot del time out: elenco JSON + file statici (range requests gestiti
|
||||||
|
// da express.static, così il seeking dei video funziona). La route /spot/list
|
||||||
|
// va prima dello static per non collidere con un eventuale file "list".
|
||||||
|
app.get('/spot/list', (_req, res) => res.json(listSpotVideos(spotDir)))
|
||||||
|
app.use('/spot', express.static(spotDir))
|
||||||
|
|
||||||
app.get(['/', '/display', '/display/*splat'], (_req, res) => {
|
app.get(['/', '/display', '/display/*splat'], (_req, res) => {
|
||||||
res.sendFile(join(distDir, 'index.html'))
|
res.sendFile(join(distDir, 'index.html'))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
|
{{ wsConnected ? 'Connesso' : 'Connessione...' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Banner modalità time out attiva -->
|
||||||
|
<div class="timeout-banner" v-if="state.timeout">⏱ MODALITÀ TIME OUT ATTIVA</div>
|
||||||
|
|
||||||
<!-- ══════════════════ MODALITÀ MOBILE ══════════════════ -->
|
<!-- ══════════════════ MODALITÀ MOBILE ══════════════════ -->
|
||||||
<template v-if="!isEstesa">
|
<template v-if="!isEstesa">
|
||||||
<!-- Anteprima punteggio -->
|
<!-- Anteprima punteggio -->
|
||||||
@@ -64,6 +67,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' })">
|
||||||
|
⏱ Time Out
|
||||||
|
</button>
|
||||||
<button class="btn btn-danger" @click="confirmReset = true">
|
<button class="btn btn-danger" @click="confirmReset = true">
|
||||||
Reset
|
Reset
|
||||||
</button>
|
</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="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 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>
|
||||||
@@ -489,6 +496,32 @@ export default {
|
|||||||
.conn-bar.connected {
|
.conn-bar.connected {
|
||||||
background: #2e7d32;
|
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 {
|
.dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
@@ -112,6 +112,12 @@
|
|||||||
{{ wsConnected ? '' : 'Disconnesso' }}
|
{{ wsConnected ? '' : 'Disconnesso' }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,6 +127,12 @@ import { createWsMixin } from '../wsMixin.js'
|
|||||||
export default {
|
export default {
|
||||||
name: "DisplayPage",
|
name: "DisplayPage",
|
||||||
mixins: [createWsMixin('display')],
|
mixins: [createWsMixin('display')],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
spotList: [], // filename dei video spot da riprodurre
|
||||||
|
spotIndex: 0, // video corrente nella sequenza
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.isMobile()) {
|
if (this.isMobile()) {
|
||||||
try { document.documentElement.requestFullscreen() } catch (e) {}
|
try { document.documentElement.requestFullscreen() } catch (e) {}
|
||||||
@@ -149,8 +161,51 @@ export default {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
'state.timeout': {
|
||||||
|
handler(attivo) {
|
||||||
|
attivo ? this.avviaTimeout() : this.fermaTimeout()
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
onWsMessage(msg) {
|
||||||
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
|
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
|
||||||
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
|
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
|
||||||
@@ -247,4 +302,28 @@ export default {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createInitialState() {
|
|||||||
order: true,
|
order: true,
|
||||||
visuForm: false,
|
visuForm: false,
|
||||||
visuStriscia: true,
|
visuStriscia: true,
|
||||||
|
timeout: false,
|
||||||
modalitaPartita: "3/5",
|
modalitaPartita: "3/5",
|
||||||
sp: {
|
sp: {
|
||||||
striscia: [{ serv: 'h', ris: '', vinc: null }],
|
striscia: [{ serv: 'h', ris: '', vinc: null }],
|
||||||
@@ -164,6 +165,11 @@ export function applyAction(state, action) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "toggleTimeout": {
|
||||||
|
s.timeout = !s.timeout
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "setNomi": {
|
case "setNomi": {
|
||||||
if (action.home !== undefined) s.sp.nomi.home = action.home
|
if (action.home !== undefined) s.sp.nomi.home = action.home
|
||||||
if (action.guest !== undefined) s.sp.nomi.guest = action.guest
|
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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { WebSocketServer } from 'ws'
|
import { WebSocketServer } from 'ws'
|
||||||
|
import express from 'express'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
import { setupWebSocketHandler } from './src/websocket-handler.js'
|
import { setupWebSocketHandler } from './src/websocket-handler.js'
|
||||||
import { printServerInfo } from './src/server-utils.js'
|
import { printServerInfo } from './src/server-utils.js'
|
||||||
import { loadState, saveState } from './src/persist.js'
|
import { loadState, saveState } from './src/persist.js'
|
||||||
|
import { listSpotVideos } from './src/spot-utils.js'
|
||||||
|
|
||||||
|
const SPOT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'spot')
|
||||||
|
|
||||||
export default function websocketPlugin() {
|
export default function websocketPlugin() {
|
||||||
return {
|
return {
|
||||||
@@ -10,6 +16,16 @@ export default function websocketPlugin() {
|
|||||||
const wss = new WebSocketServer({ noServer: true })
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
|
setupWebSocketHandler(wss, { initialState: loadState(), onStateChange: saveState })
|
||||||
|
|
||||||
|
// Video spot del time out: stesso comportamento di server.js (elenco + statici)
|
||||||
|
server.middlewares.use('/spot', (req, res, next) => {
|
||||||
|
if (req.url === '/list' || req.url === '/list/') {
|
||||||
|
res.setHeader('Content-Type', 'application/json')
|
||||||
|
return res.end(JSON.stringify(listSpotVideos(SPOT_DIR)))
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
server.middlewares.use('/spot', express.static(SPOT_DIR))
|
||||||
|
|
||||||
// Rewrite /display → / (index.html) e /controller → /controller.html
|
// Rewrite /display → / (index.html) e /controller → /controller.html
|
||||||
server.middlewares.use((req, _res, next) => {
|
server.middlewares.use((req, _res, next) => {
|
||||||
if (req.url === '/display' || req.url === '/display/') req.url = '/'
|
if (req.url === '/display' || req.url === '/display/') req.url = '/'
|
||||||
|
|||||||
Reference in New Issue
Block a user