diff --git a/CLAUDE.md b/CLAUDE.md index 9a04724..0dc0289 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,3 +91,4 @@ Il volume `./.segnapunti` persiste lo stato tra i riavvii del container. - Qualsiasi modifica al protocollo WebSocket va fatta in `src/websocket-handler.js`. - Aggiungere test unit in `tests/unit/gameState.test.js` per ogni nuovo action type o regola di gioco. - Il codice deve essere scritto in italiano per commenti e nomi di variabili di dominio (es. `servHome`, `striscia`, `nomi`), ma in inglese per nomi tecnici standard (`state`, `action`, `handler`). +- Non inserire emoji in codice, UI (label, testi dei bottoni, banner) o commenti, a meno che l'utente non le richieda esplicitamente. diff --git a/src/components/ControllerPage.vue b/src/components/ControllerPage.vue index c643eb9..c402699 100644 --- a/src/components/ControllerPage.vue +++ b/src/components/ControllerPage.vue @@ -7,7 +7,9 @@ -
+ @@ -67,8 +69,9 @@ - @@ -304,6 +345,13 @@ export default { formGuest: ["1", "2", "3", "4", "5", "6"], }, isLandscape: window.innerWidth > window.innerHeight, + showTimeoutModal: false, + timeoutTeamSel: null, + timeoutVideoSel: null, + spotList: [], + spotDurations: {}, + nowTick: Date.now(), + timeoutTicker: null, } }, computed: { @@ -338,6 +386,17 @@ export default { } return hasComplete }, + 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}` + }, }, watch: { squadraVincente(val) { @@ -346,6 +405,12 @@ export default { this.showSetVinto = true } }, + 'state.timeout': { + immediate: true, + handler(attivo) { + attivo ? this.avviaTimeoutTicker() : this.fermaTimeoutTicker() + }, + }, }, mounted() { this._resizeHandler = () => { this.isLandscape = window.innerWidth > window.innerHeight } @@ -355,6 +420,7 @@ export default { beforeUnmount() { window.removeEventListener('resize', this._resizeHandler) window.removeEventListener('orientationchange', this._resizeHandler) + this.fermaTimeoutTicker() }, methods: { generaReferto, @@ -458,6 +524,47 @@ export default { : this.servHome ? `${home} a ${guest}` : `${guest} a ${home}` this.sendWs({ type: 'speak', text }) }, + avviaTimeoutTicker() { + this.fermaTimeoutTicker() + this.nowTick = Date.now() + this.timeoutTicker = setInterval(() => { this.nowTick = Date.now() }, 250) + }, + fermaTimeoutTicker() { + if (this.timeoutTicker) { clearInterval(this.timeoutTicker); this.timeoutTicker = null } + }, + async openTimeout() { + this.timeoutTeamSel = null + this.timeoutVideoSel = null + this.spotList = [] + this.spotDurations = {} + this.showTimeoutModal = true + try { + const res = await fetch('/spot/list') + this.spotList = await res.json() + } catch { + this.spotList = [] + } + this.spotList.forEach(file => this.probeDurata(file)) + }, + probeDurata(file) { + const video = document.createElement('video') + video.preload = 'metadata' + video.onloadedmetadata = () => { + this.spotDurations = { ...this.spotDurations, [file]: video.duration } + } + video.src = '/spot/' + encodeURIComponent(file) + }, + formatDurata(sec) { + return typeof sec === 'number' && isFinite(sec) ? `${Math.round(sec)}s` : '…' + }, + avviaTimeout() { + if (!this.timeoutTeamSel) return + this.sendAction({ type: 'startTimeout', team: this.timeoutTeamSel, video: this.timeoutVideoSel }) + this.showTimeoutModal = false + }, + terminaTimeout() { + this.sendAction({ type: 'stopTimeout' }) + }, }, } @@ -1070,4 +1177,37 @@ export default { padding: 8px 0; font-weight: 600; } + +/* Modal avvio time out */ +.dialog-timeout { + max-height: 85vh; + overflow-y: auto; +} +.btn-set.team-sel-active { + box-shadow: 0 0 0 3px rgba(255,255,255,0.6) inset; +} +.timeout-video-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 40vh; + overflow-y: auto; +} +.timeout-video-option { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255,255,255,0.06); + border: 1px solid rgba(255,255,255,0.12); + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; + cursor: pointer; +} +.video-duration { + margin-left: auto; + color: #aaa; + font-size: 12px; + font-variant-numeric: tabular-nums; +} diff --git a/src/components/DisplayPage.vue b/src/components/DisplayPage.vue index 350df4e..4d66b75 100644 --- a/src/components/DisplayPage.vue +++ b/src/components/DisplayPage.vue @@ -113,10 +113,14 @@ - + @@ -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; } diff --git a/src/gameState.js b/src/gameState.js index cd5997c..d27491e 100644 --- a/src/gameState.js +++ b/src/gameState.js @@ -22,6 +22,9 @@ export function createInitialState() { visuForm: false, visuStriscia: true, timeout: false, + timeoutTeam: null, + timeoutVideo: null, + timeoutStartedAt: null, modalitaPartita: "3/5", sp: { striscia: [{ serv: 'h', ris: '', vinc: null }], @@ -165,8 +168,21 @@ export function applyAction(state, action) { break } - case "toggleTimeout": { - s.timeout = !s.timeout + case "startTimeout": { + const team = action.team + if (team !== 'home' && team !== 'guest') break + s.timeout = true + s.timeoutTeam = team + s.timeoutVideo = action.video ?? null + s.timeoutStartedAt = action.startedAt ?? null + break + } + + case "stopTimeout": { + s.timeout = false + s.timeoutTeam = null + s.timeoutVideo = null + s.timeoutStartedAt = null break } diff --git a/src/websocket-handler.js b/src/websocket-handler.js index faf7150..77e8eaa 100644 --- a/src/websocket-handler.js +++ b/src/websocket-handler.js @@ -5,6 +5,8 @@ import { createInitialState, applyAction } from './gameState.js' * @param {WebSocketServer} wss - Istanza del server WebSocket. * @returns {Object} Oggetto con metodi di gestione dello stato. */ +const DURATA_TIMEOUT_MS = 30000 + export function setupWebSocketHandler(wss, options = {}) { // Stato globale della partita. let gameState = options.initialState ?? createInitialState() @@ -12,6 +14,17 @@ export function setupWebSocketHandler(wss, options = {}) { // Mappa dei ruoli associati ai client connessi. const clients = new Map() // ws -> { role: 'display' | 'controller' } + // Timer che chiude automaticamente il time out dopo 30s, indipendentemente + // dal controller (sopravvive a refresh/disconnessione/standby del tablet). + let timeoutTimer = null + + function annullaTimerTimeout() { + if (timeoutTimer) { + clearTimeout(timeoutTimer) + timeoutTimer = null + } + } + /** * Gestisce i messaggi in arrivo dal client */ @@ -87,10 +100,16 @@ export function setupWebSocketHandler(wss, options = {}) { return } + // Il momento di inizio del time out lo decide il server (non il client), + // cosi' gameState.js resta puro/deterministico e testabile. + const action = msg.action.type === 'startTimeout' + ? { ...msg.action, startedAt: Date.now() } + : msg.action + // Applica l'azione allo stato della partita. const previousState = gameState try { - gameState = applyAction(gameState, msg.action) + gameState = applyAction(gameState, action) } catch (err) { console.error('Error applying action:', err) sendError(ws, 'Failed to apply action') @@ -98,6 +117,20 @@ export function setupWebSocketHandler(wss, options = {}) { return } + // Qualsiasi azione che tocca il time out invalida il timer di auto-chiusura + // pendente; se il time out e' (ri)partito, ne arma uno nuovo. + if (action.type === 'startTimeout' || action.type === 'stopTimeout') { + annullaTimerTimeout() + } + if (action.type === 'startTimeout' && gameState.timeout) { + timeoutTimer = setTimeout(() => { + timeoutTimer = null + gameState = applyAction(gameState, { type: 'stopTimeout' }) + broadcastState() + options.onStateChange?.(gameState) + }, DURATA_TIMEOUT_MS) + } + // Propaga il nuovo stato a tutti i client connessi. broadcastState() options.onStateChange?.(gameState) diff --git a/tests/integration/websocket.test.js b/tests/integration/websocket.test.js index 21337a6..5edf11a 100644 --- a/tests/integration/websocket.test.js +++ b/tests/integration/websocket.test.js @@ -164,6 +164,117 @@ describe('WebSocket Integration (websocket-handler.js)', () => { }) }) + // ============================================= + // TIME OUT (timer server-side) + // ============================================= + describe('Time out', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function inviaStartTimeout(controller, overrides = {}) { + controller.emit('message', JSON.stringify({ + type: 'action', + action: { type: 'startTimeout', team: 'home', video: 'spot.mp4', ...overrides } + })) + } + + it('dovrebbe iniettare startedAt lato server (ignorando quello del client)', () => { + vi.setSystemTime(50000) + const controller = connectAndRegister(wss, 'controller') + + inviaStartTimeout(controller, { startedAt: 12345 }) + + const state = handler.getState() + expect(state.timeout).toBe(true) + expect(state.timeoutStartedAt).toBe(50000) + }) + + it('dovrebbe chiudere automaticamente il time out dopo 30 secondi', () => { + const controller = connectAndRegister(wss, 'controller') + const display = connectAndRegister(wss, 'display') + + inviaStartTimeout(controller) + expect(handler.getState().timeout).toBe(true) + + vi.advanceTimersByTime(29999) + expect(handler.getState().timeout).toBe(true) + + vi.advanceTimersByTime(1) + const state = handler.getState() + expect(state.timeout).toBe(false) + expect(state.timeoutTeam).toBe(null) + expect(state.timeoutVideo).toBe(null) + expect(state.timeoutStartedAt).toBe(null) + + // La chiusura automatica viene broadcastata ai client + const msg = lastSent(display) + expect(msg.type).toBe('state') + expect(msg.state.timeout).toBe(false) + }) + + it('la chiusura automatica dovrebbe persistere lo stato (onStateChange)', () => { + const onStateChange = vi.fn() + wss = new MockWebSocketServer() + handler = setupWebSocketHandler(wss, { onStateChange }) + const controller = connectAndRegister(wss, 'controller') + + inviaStartTimeout(controller) + onStateChange.mockClear() + + vi.advanceTimersByTime(30000) + expect(onStateChange).toHaveBeenCalledTimes(1) + expect(onStateChange.mock.calls[0][0].timeout).toBe(false) + }) + + it('stopTimeout manuale dovrebbe annullare il timer pendente', () => { + const controller = connectAndRegister(wss, 'controller') + + inviaStartTimeout(controller) + controller.emit('message', JSON.stringify({ + type: 'action', + action: { type: 'stopTimeout' } + })) + expect(handler.getState().timeout).toBe(false) + controller.send.mockClear() + + // Allo scadere dei 30s non deve partire nessun broadcast fantasma + vi.advanceTimersByTime(30000) + expect(controller.send).not.toHaveBeenCalled() + }) + + it('un nuovo startTimeout dovrebbe riarmare il timer da capo', () => { + const controller = connectAndRegister(wss, 'controller') + + inviaStartTimeout(controller) + vi.advanceTimersByTime(20000) + + // Riavvio: il timer riparte da zero + inviaStartTimeout(controller, { team: 'guest' }) + vi.advanceTimersByTime(20000) + expect(handler.getState().timeout).toBe(true) + expect(handler.getState().timeoutTeam).toBe('guest') + + vi.advanceTimersByTime(10000) + expect(handler.getState().timeout).toBe(false) + }) + + it('startTimeout con squadra non valida non dovrebbe armare il timer', () => { + const controller = connectAndRegister(wss, 'controller') + + inviaStartTimeout(controller, { team: 'arbitro' }) + expect(handler.getState().timeout).toBe(false) + controller.send.mockClear() + + vi.advanceTimersByTime(30000) + expect(controller.send).not.toHaveBeenCalled() + }) + }) + // ============================================= // BROADCAST MULTI-CLIENT // ============================================= diff --git a/tests/unit/gameState.test.js b/tests/unit/gameState.test.js index 6ef3818..147fa1d 100644 --- a/tests/unit/gameState.test.js +++ b/tests/unit/gameState.test.js @@ -80,8 +80,11 @@ describe('Game Logic (gameState.js)', () => { expect(state.visuStriscia).toBe(true) }) - it('dovrebbe avere timeout false', () => { + it('dovrebbe avere timeout false e campi timeout nulli', () => { expect(state.timeout).toBe(false) + expect(state.timeoutTeam).toBe(null) + expect(state.timeoutVideo).toBe(null) + expect(state.timeoutStartedAt).toBe(null) }) }) @@ -484,12 +487,50 @@ describe('Game Logic (gameState.js)', () => { expect(s.order).toBe(false) }) - it('toggleTimeout: false → true → false', () => { - expect(state.timeout).toBe(false) - const s1 = applyAction(state, { type: 'toggleTimeout' }) - expect(s1.timeout).toBe(true) - const s2 = applyAction(s1, { type: 'toggleTimeout' }) - expect(s2.timeout).toBe(false) + }) + + // ============================================= + // TIME OUT (startTimeout, stopTimeout) + // ============================================= + describe('Time out', () => { + it('startTimeout dovrebbe attivare il time out con squadra, video e inizio', () => { + const s = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 }) + expect(s.timeout).toBe(true) + expect(s.timeoutTeam).toBe('home') + expect(s.timeoutVideo).toBe('spot.mp4') + expect(s.timeoutStartedAt).toBe(1000) + }) + + it('startTimeout senza video dovrebbe lasciare timeoutVideo null', () => { + const s = applyAction(state, { type: 'startTimeout', team: 'guest', startedAt: 1000 }) + expect(s.timeout).toBe(true) + expect(s.timeoutTeam).toBe('guest') + expect(s.timeoutVideo).toBe(null) + }) + + it('startTimeout senza squadra valida dovrebbe essere ignorato', () => { + for (const team of [undefined, null, 'arbitro', '']) { + const s = applyAction(state, { type: 'startTimeout', team, video: 'spot.mp4', startedAt: 1000 }) + expect(s.timeout).toBe(false) + expect(s.timeoutTeam).toBe(null) + expect(s.timeoutVideo).toBe(null) + expect(s.timeoutStartedAt).toBe(null) + } + }) + + it('stopTimeout dovrebbe azzerare tutti i campi del time out', () => { + const attivo = applyAction(state, { type: 'startTimeout', team: 'home', video: 'spot.mp4', startedAt: 1000 }) + const s = applyAction(attivo, { type: 'stopTimeout' }) + expect(s.timeout).toBe(false) + expect(s.timeoutTeam).toBe(null) + expect(s.timeoutVideo).toBe(null) + expect(s.timeoutStartedAt).toBe(null) + }) + + it('stopTimeout su time out già inattivo non dovrebbe avere effetti', () => { + const s = applyAction(state, { type: 'stopTimeout' }) + expect(s.timeout).toBe(false) + expect(s.timeoutTeam).toBe(null) }) })