From 9923cbbd5da80aedb3d2f0050060d3a7b5b5fbdf Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Fri, 13 Feb 2026 14:45:45 +0100 Subject: [PATCH] feat(flightlab): add WebSocket sync composable for instructor-participant sessions Co-Authored-By: Claude Opus 4.6 --- .../composables/flightlab/useFlightLabSync.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 shared/composables/flightlab/useFlightLabSync.ts diff --git a/shared/composables/flightlab/useFlightLabSync.ts b/shared/composables/flightlab/useFlightLabSync.ts new file mode 100644 index 0000000..97f3aaa --- /dev/null +++ b/shared/composables/flightlab/useFlightLabSync.ts @@ -0,0 +1,139 @@ +// shared/composables/flightlab/useFlightLabSync.ts +import { ref } from 'vue' +import type { FlightLabRole, FlightLabSessionState } from '../../data/flightlab/types' + +export function useFlightLabSync() { + const ws = ref(null) + const isConnected = ref(false) + const role = ref(null) + const sessionCode = ref(null) + const remoteState = ref(null) + + const callbacks = { + onStateChange: [] as Array<(state: FlightLabSessionState, action?: any) => void>, + onInstructorMessage: [] as Array<(text: string, withRadioEffect: boolean) => void>, + onPeerJoined: [] as Array<(peerRole: FlightLabRole) => void>, + onPeerLeft: [] as Array<(peerRole: FlightLabRole) => void>, + onError: [] as Array<(msg: string) => void>, + } + + function connect(): Promise { + return new Promise((resolve, reject) => { + const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const url = `${proto}//${window.location.host}/api/flightlab/ws` + ws.value = new WebSocket(url) + + ws.value.onopen = () => { + isConnected.value = true + resolve() + } + + ws.value.onclose = () => { + isConnected.value = false + ws.value = null + } + + ws.value.onerror = () => { + reject(new Error('WebSocket connection failed')) + } + + ws.value.onmessage = (event) => { + let data: any + try { data = JSON.parse(event.data) } catch { return } + handleMessage(data) + } + }) + } + + function handleMessage(data: any) { + switch (data.type) { + case 'session-created': + sessionCode.value = data.code + role.value = 'instructor' + remoteState.value = data.state + break + case 'session-joined': + role.value = data.role + remoteState.value = data.state + sessionCode.value = data.state.sessionCode + break + case 'state-change': + remoteState.value = data.state + callbacks.onStateChange.forEach(cb => cb(data.state, data.action)) + break + case 'instructor-message': + callbacks.onInstructorMessage.forEach(cb => cb(data.text, data.withRadioEffect)) + break + case 'peer-joined': + callbacks.onPeerJoined.forEach(cb => cb(data.role)) + break + case 'peer-left': + callbacks.onPeerLeft.forEach(cb => cb(data.role)) + break + case 'error': + callbacks.onError.forEach(cb => cb(data.message)) + break + } + } + + function send(data: any) { + if (ws.value?.readyState === WebSocket.OPEN) { + ws.value.send(JSON.stringify(data)) + } + } + + async function createSession(scenarioId = 'takeoff-eddf') { + await connect() + send({ type: 'create-session', scenarioId }) + } + + async function joinSession(code: string, joinRole: FlightLabRole = 'participant') { + await connect() + send({ type: 'join-session', code: code.toUpperCase(), role: joinRole }) + } + + function sendParticipantAction(phaseId: string, buttonId: string, nextPhaseId: string) { + send({ type: 'participant-action', phaseId, buttonId, nextPhaseId }) + } + + function sendInstructorCommand(command: string, targetPhaseId?: string) { + send({ type: 'instructor-command', command, targetPhaseId }) + } + + function sendInstructorMessage(text: string, withRadioEffect = true) { + send({ type: 'instructor-message', text, withRadioEffect }) + } + + function disconnect() { + ws.value?.close() + ws.value = null + isConnected.value = false + role.value = null + sessionCode.value = null + remoteState.value = null + } + + function onStateChange(cb: (state: FlightLabSessionState, action?: any) => void) { callbacks.onStateChange.push(cb) } + function onInstructorMessage(cb: (text: string, withRadioEffect: boolean) => void) { callbacks.onInstructorMessage.push(cb) } + function onPeerJoined(cb: (role: FlightLabRole) => void) { callbacks.onPeerJoined.push(cb) } + function onPeerLeft(cb: (role: FlightLabRole) => void) { callbacks.onPeerLeft.push(cb) } + function onError(cb: (msg: string) => void) { callbacks.onError.push(cb) } + + return { + isConnected, + role, + sessionCode, + remoteState, + createSession, + joinSession, + sendParticipantAction, + sendInstructorCommand, + sendInstructorMessage, + disconnect, + onStateChange, + onInstructorMessage, + onPeerJoined, + onPeerLeft, + onError, + } +}