diff --git a/.gitignore b/.gitignore index 738e140..eb867fe 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dist dist-ssr dev-dist .segnapunti +spot *.local # Editor directories and files diff --git a/server.js b/server.js index 6198b49..6d21dd5 100644 --- a/server.js +++ b/server.js @@ -6,19 +6,27 @@ import { dirname, join } from 'path' import { setupWebSocketHandler } from './src/websocket-handler.js' import { printServerInfo } from './src/server-utils.js' import { loadState, saveState } from './src/persist.js' +import { listSpotVideos } from './src/spot-utils.js' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const DIST_DIR = join(__dirname, 'dist') +const SPOT_DIR = join(__dirname, 'spot') // Crea l'app Express (asset statici + route display/controller) senza avviare il // 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() 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) => { res.sendFile(join(distDir, 'index.html')) }) diff --git a/src/components/ControllerPage.vue b/src/components/ControllerPage.vue index 758a9f0..c643eb9 100644 --- a/src/components/ControllerPage.vue +++ b/src/components/ControllerPage.vue @@ -6,6 +6,9 @@ {{ wsConnected ? 'Connesso' : 'Connessione...' }} + +
+ @@ -64,6 +67,9 @@ + @@ -142,6 +148,7 @@ + @@ -489,6 +496,32 @@ export default { .conn-bar.connected { 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 { width: 8px; height: 8px; diff --git a/src/components/DisplayPage.vue b/src/components/DisplayPage.vue index ce48988..4dd0b91 100644 --- a/src/components/DisplayPage.vue +++ b/src/components/DisplayPage.vue @@ -112,6 +112,12 @@ {{ wsConnected ? '' : 'Disconnesso' }} + + + @@ -121,6 +127,12 @@ import { createWsMixin } from '../wsMixin.js' export default { name: "DisplayPage", mixins: [createWsMixin('display')], + data() { + return { + spotList: [], // filename dei video spot da riprodurre + spotIndex: 0, // video corrente nella sequenza + } + }, mounted() { if (this.isMobile()) { try { document.documentElement.requestFullscreen() } catch (e) {} @@ -149,8 +161,51 @@ export default { }) } }, + 'state.timeout': { + handler(attivo) { + attivo ? this.avviaTimeout() : this.fermaTimeout() + }, + }, }, 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) { if (msg.type === 'speak') this.speakOnDisplay(msg.text) else if (msg.type === 'error') console.error('[Display] Server error:', msg.message) @@ -247,4 +302,28 @@ export default { overflow: hidden; 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; +} diff --git a/src/gameState.js b/src/gameState.js index 77990b7..cd5997c 100644 --- a/src/gameState.js +++ b/src/gameState.js @@ -21,6 +21,7 @@ export function createInitialState() { order: true, visuForm: false, visuStriscia: true, + timeout: false, modalitaPartita: "3/5", sp: { striscia: [{ serv: 'h', ris: '', vinc: null }], @@ -164,6 +165,11 @@ export function applyAction(state, action) { break } + case "toggleTimeout": { + s.timeout = !s.timeout + break + } + case "setNomi": { if (action.home !== undefined) s.sp.nomi.home = action.home if (action.guest !== undefined) s.sp.nomi.guest = action.guest diff --git a/src/spot-utils.js b/src/spot-utils.js new file mode 100644 index 0000000..ed60b96 --- /dev/null +++ b/src/spot-utils.js @@ -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 [] + } +} diff --git a/vite-plugin-websocket.js b/vite-plugin-websocket.js index 3f10bb5..c621d11 100644 --- a/vite-plugin-websocket.js +++ b/vite-plugin-websocket.js @@ -1,7 +1,13 @@ 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 { printServerInfo } from './src/server-utils.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() { return { @@ -10,6 +16,16 @@ export default function websocketPlugin() { const wss = new WebSocketServer({ noServer: true }) 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 server.middlewares.use((req, _res, next) => { if (req.url === '/display' || req.url === '/display/') req.url = '/'