refactor: deriva punt/set/servHome dalla striscia, estrai mixin WebSocket

La striscia è ora l'unica source of truth: punt, set vinti e servizio
vengono calcolati via helper puri (punteggio/servizio/setVinti) invece
di essere mantenuti come campi ridondanti nello stato.

Il boilerplate WebSocket (~150 righe identiche in entrambi i componenti)
è estratto in src/wsMixin.js con createWsMixin(role); i componenti
diventano solo logica specifica del loro ruolo.
This commit is contained in:
2026-06-20 15:23:53 +02:00
parent 8e2f3d759d
commit 38703116ff
4 changed files with 274 additions and 561 deletions
+29 -223
View File
@@ -7,22 +7,22 @@
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.home }}
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.home }}</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.home }}</span>
</div>
<div class="hea guest">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.guest }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.guest }}</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.guest }}</span>
</div>
<span v-if="state.visuForm">
@@ -39,8 +39,8 @@
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ punt.home }}</div>
<div class="col punt guest">{{ punt.guest }}</div>
</div>
</span>
</span>
@@ -51,22 +51,22 @@
<span :style="{ 'float': 'left' }">
{{ state.sp.nomi.guest }}
<span class="serv-slot">
<img v-show="!state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="!servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.guest }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.guest }}</span>
</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ state.sp.set.guest }}</span>
<span class="mr3" :style="{ 'float': 'right' }">set {{ set.guest }}</span>
</div>
<div class="hea home">
<span :style="{ 'float': 'right' }">
<span v-if="state.visuForm" class="score-inline">{{ state.sp.punt.home }}</span>
<span v-if="state.visuForm" class="score-inline">{{ punt.home }}</span>
<span class="serv-slot">
<img v-show="state.sp.servHome" src="/serv.png" width="25" alt="Servizio" />
<img v-show="servHome" src="/serv.png" width="25" alt="Servizio" />
</span>
{{ state.sp.nomi.home }}
</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ state.sp.set.home }}</span>
<span class="ml3" :style="{ 'float': 'left' }">set {{ set.home }}</span>
</div>
<span v-if="state.visuForm">
@@ -83,8 +83,8 @@
</span>
<span v-else>
<div class="punteggio-container">
<div class="col punt guest">{{ state.sp.punt.guest }}</div>
<div class="col punt home">{{ state.sp.punt.home }}</div>
<div class="col punt guest">{{ punt.guest }}</div>
<div class="col punt home">{{ punt.home }}</div>
</div>
</span>
</span>
@@ -116,80 +116,15 @@
</template>
<script>
import { createWsMixin } from '../wsMixin.js'
export default {
name: "DisplayPage",
data() {
return {
ws: null,
wsConnected: false,
isConnecting: false,
reconnectTimeout: null,
reconnectAttempts: 0,
maxReconnectDelay: 30000, // Ritardo massimo di riconnessione: 30 secondi
state: {
order: true,
visuForm: false,
visuStriscia: true,
modalitaPartita: "3/5",
sp: {
striscia: [{ serv: 'h', ris: '' }],
servHome: true,
punt: { home: 0, guest: 0 },
set: { home: 0, guest: 0 },
nomi: { home: "Antoniana", guest: "Guest" },
form: {
home: ["1", "2", "3", "4", "5", "6"],
guest: ["1", "2", "3", "4", "5", "6"],
},
},
},
}
},
mixins: [createWsMixin('display')],
mounted() {
this.connectWebSocket()
// Attiva la modalita fullscreen su dispositivi mobili.
if (this.isMobile()) {
try { document.documentElement.requestFullscreen() } catch (e) {}
}
// Gestisce l'HMR di Vite evitando riconnessioni durante la ricarica a caldo.
if (import.meta.hot) {
import.meta.hot.on('vite:beforeUpdate', () => {
// Annulla eventuali tentativi di riconnessione pianificati.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
})
}
},
beforeUnmount() {
// Pulisce il timeout di riconnessione.
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
// Chiude il WebSocket con il codice di chiusura appropriato.
if (this.ws) {
this.ws.onclose = null // Rimuove il listener per evitare nuove riconnessioni pianificate.
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
// Usa il codice 1000 (chiusura normale) se la connessione e aperta.
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Component unmounting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Se la connessione e ancora in fase di apertura, chiude direttamente.
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing WebSocket:', err)
}
this.ws = null
}
},
computed: {
stricciaStrip() {
@@ -216,154 +151,25 @@ export default {
},
},
methods: {
onWsMessage(msg) {
if (msg.type === 'speak') this.speakOnDisplay(msg.text)
else if (msg.type === 'error') console.error('[Display] Server error:', msg.message)
},
isMobile() {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
},
connectWebSocket() {
// Evita connessioni simultanee multiple.
if (this.isConnecting) {
console.log('[Display] Already connecting, skipping...')
return
}
// Chiude la connessione precedente, se presente.
if (this.ws) {
this.ws.onclose = null
this.ws.onerror = null
this.ws.onmessage = null
this.ws.onopen = null
try {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.close(1000, 'Reconnecting')
} else if (this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close()
}
} catch (err) {
console.error('[Display] Error closing previous WebSocket:', err)
}
this.ws = null
}
this.isConnecting = true
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
// Permette di specificare un host WebSocket alternativo via query parameter
// Utile per scenari WSL2 o development remoto: ?wsHost=192.168.1.100:5173
const params = new URLSearchParams(location.search)
const defaultHost = (
location.hostname === 'localhost' || location.hostname === '::1'
)
? `127.0.0.1${location.port ? `:${location.port}` : ''}`
: location.host
const wsHost = params.get('wsHost') || defaultHost
const wsUrl = `${protocol}//${wsHost}/ws`
console.log('[Display] Connecting to WebSocket:', wsUrl)
try {
this.ws = new WebSocket(wsUrl)
} catch (err) {
console.error('[Display] Failed to create WebSocket:', err)
this.isConnecting = false
this.scheduleReconnect()
return
}
this.ws.onopen = () => {
this.isConnecting = false
this.wsConnected = true
this.reconnectAttempts = 0
console.log('[Display] Connected to server')
// Registra il client come display solo con connessione effettivamente aperta.
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
try {
this.ws.send(JSON.stringify({ type: 'register', role: 'display' }))
} catch (err) {
console.error('[Display] Failed to register:', err)
}
}
}
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'state') {
this.state = msg.state
} else if (msg.type === 'speak') {
this.speakOnDisplay(msg.text)
} else if (msg.type === 'error') {
console.error('[Display] Server error:', msg.message)
}
} catch (e) {
console.error('[Display] Error parsing message:', e)
}
}
this.ws.onclose = (event) => {
this.isConnecting = false
this.wsConnected = false
console.log('[Display] Disconnected from server', event.code, event.reason)
// Non riconnette durante HMR (codice 1001, "going away")
// ne in caso di chiusura pulita (codice 1000).
if (event.code === 1000 || event.code === 1001) {
console.log('[Display] Clean close, not reconnecting')
return
}
this.scheduleReconnect()
}
this.ws.onerror = (err) => {
console.error('[Display] WebSocket error:', err)
this.isConnecting = false
this.wsConnected = false
}
},
scheduleReconnect() {
// Evita pianificazioni multiple della riconnessione.
if (this.reconnectTimeout) {
return
}
// Applica backoff esponenziale: 1s, 2s, 4s, 8s, 16s, fino a 30s.
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay
)
this.reconnectAttempts++
console.log(`[Display] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectWebSocket()
}, delay)
},
speakOnDisplay(text) {
if (typeof text !== 'string' || !text.trim()) {
return
}
if (!('speechSynthesis' in window)) {
console.warn('[Display] speechSynthesis not supported')
return
}
if (typeof text !== 'string' || !text.trim() || !('speechSynthesis' in window)) return
const utterance = new SpeechSynthesisUtterance(text.trim())
const voices = window.speechSynthesis.getVoices()
const preferredVoice = voices.find((v) => v.name === 'Google italiano')
|| voices.find((v) => v.lang && v.lang.toLowerCase().startsWith('it'))
if (preferredVoice) {
utterance.voice = preferredVoice
}
utterance.voice = voices.find(v => v.name === 'Google italiano')
|| voices.find(v => v.lang?.toLowerCase().startsWith('it'))
|| null
utterance.lang = 'it-IT'
window.speechSynthesis.cancel()
window.speechSynthesis.speak(utterance)
}
}
},
},
}
</script>