Files
OpenSquawk/shared/composables/flightlab/useFlightLabSync.ts
itsrubberduck 77ecd49334 feat(flightlab): sidebar, progress bars, skip speech, SimBridge telemetry & auth
- Add collapsible sidebar with phase stepper (jump between phases)
- Add SimBridge conditions panel in sidebar (live values, progress bars, targets)
- Add global progress bar (top edge, glowing) + phase-local TTS progress bar
- Add skip button to skip TTS speech while ATC is speaking
- Add skipSpeech() to audio composable (stops current Pizzicato sound)
- Wire up bridge data.post.ts with user auth (JWT) + example payload
- Add server-side telemetry store with pub/sub for Bridge→WS relay
- Extend WS handler with subscribe-telemetry message + userId tracking
- Extend sync composable with subscribeTelemetry() + onTelemetry() callback
- Add require-auth middleware to all flightlab pages
- Fix instructor station ECONNREFUSED via import.meta.client guard
- Add animations: phase transitions, button lists, fade-scale, check-pop, pulse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 10:14:33 +01:00

152 lines
4.8 KiB
TypeScript

// shared/composables/flightlab/useFlightLabSync.ts
import { ref } from 'vue'
import type { FlightLabRole, FlightLabSessionState } from '../../data/flightlab/types'
export function useFlightLabSync() {
const ws = ref<WebSocket | null>(null)
const isConnected = ref(false)
const role = ref<FlightLabRole | null>(null)
const sessionCode = ref<string | null>(null)
const remoteState = ref<FlightLabSessionState | null>(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>,
onTelemetry: [] as Array<(data: any) => void>,
onError: [] as Array<(msg: string) => void>,
}
function connect(): Promise<void> {
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 'telemetry':
callbacks.onTelemetry.forEach(cb => cb(data.data))
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 })
}
/** Subscribe this session to receive telemetry for the given userId */
function subscribeTelemetry(userId: string) {
send({ type: 'subscribe-telemetry', userId })
}
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 onTelemetry(cb: (data: any) => void) { callbacks.onTelemetry.push(cb) }
function onError(cb: (msg: string) => void) { callbacks.onError.push(cb) }
return {
isConnected,
role,
sessionCode,
remoteState,
createSession,
joinSession,
subscribeTelemetry,
sendParticipantAction,
sendInstructorCommand,
sendInstructorMessage,
disconnect,
onStateChange,
onInstructorMessage,
onPeerJoined,
onPeerLeft,
onTelemetry,
onError,
}
}