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>
This commit is contained in:
itsrubberduck
2026-02-14 10:14:33 +01:00
parent 2efa24f7f5
commit 77ecd49334
8 changed files with 722 additions and 175 deletions

View File

@@ -126,6 +126,7 @@ import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
definePageMeta({
layout: false,
middleware: ['require-auth'],
})
useHead({

View File

@@ -1,7 +1,157 @@
<template>
<div class="min-h-screen bg-[#070d1a] text-white flex flex-col">
<!-- Header Bar -->
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3">
<div class="min-h-screen bg-[#070d1a] text-white flex flex-col overflow-hidden">
<!-- Global Progress Bar (top edge) -->
<div class="fixed top-0 left-0 right-0 z-50 h-1 bg-white/5">
<div
class="h-full bg-gradient-to-r from-cyan-500 via-cyan-400 to-emerald-400 transition-all duration-1000 ease-out global-progress-glow"
:style="{ width: `${engine.progress.value}%` }"
/>
</div>
<!-- Sidebar Toggle Button -->
<button
class="fixed left-0 top-1/2 -translate-y-1/2 z-40 flex h-12 w-6 items-center justify-center rounded-r-lg border border-l-0 border-white/10 bg-[#0b1328]/95 backdrop-blur text-white/40 hover:text-white/80 hover:bg-[#0b1328] transition-all duration-300"
:class="{ 'left-[300px]': sidebarOpen }"
@click="sidebarOpen = !sidebarOpen"
>
<v-icon :icon="sidebarOpen ? 'mdi-chevron-left' : 'mdi-chevron-right'" size="16" />
</button>
<!-- Sidebar -->
<Transition name="sidebar">
<aside
v-show="sidebarOpen"
class="fixed left-0 top-0 bottom-0 z-30 w-[300px] border-r border-white/5 bg-[#070d1a]/98 backdrop-blur-xl flex flex-col pt-6"
>
<!-- Sidebar Header -->
<div class="px-4 mb-4">
<div class="flex items-center gap-2 mb-1">
<v-icon icon="mdi-airplane-takeoff" size="18" class="text-cyan-400/70" />
<span class="text-sm font-medium text-white/70">{{ engine.scenario.title }}</span>
</div>
<div class="flex items-center gap-2 mt-2">
<div class="h-1 flex-1 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-cyan-500 to-cyan-300 transition-all duration-700"
:style="{ width: `${engine.progress.value}%` }"
/>
</div>
<span class="text-xs text-white/40 tabular-nums">{{ engine.progress.value }}%</span>
</div>
</div>
<!-- Phase Stepper -->
<div class="flex-1 overflow-y-auto px-4 pb-4 sidebar-scroll">
<p class="text-[10px] uppercase tracking-widest text-white/30 mb-3">Phasen</p>
<div class="space-y-1">
<button
v-for="(phaseId, idx) in mainPhaseIds"
:key="phaseId"
class="group flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-all duration-300"
:class="getPhaseStepClass(phaseId, idx)"
@click="handlePhaseJump(phaseId)"
>
<!-- Step indicator -->
<div class="relative flex h-7 w-7 shrink-0 items-center justify-center">
<!-- Completed check -->
<div
v-if="isPhaseCompleted(phaseId, idx)"
class="h-7 w-7 rounded-full bg-emerald-500/20 border border-emerald-400/40 flex items-center justify-center phase-check-enter"
>
<v-icon icon="mdi-check" size="14" class="text-emerald-300" />
</div>
<!-- Current pulse -->
<div
v-else-if="isPhaseActive(phaseId)"
class="h-7 w-7 rounded-full bg-cyan-500/20 border-2 border-cyan-400 flex items-center justify-center"
>
<div class="h-2.5 w-2.5 rounded-full bg-cyan-400 animate-pulse" />
</div>
<!-- Future -->
<div v-else class="h-7 w-7 rounded-full bg-white/5 border border-white/10 flex items-center justify-center">
<span class="text-[10px] text-white/30 font-mono">{{ idx + 1 }}</span>
</div>
<!-- Connecting line -->
<div
v-if="idx < mainPhaseIds.length - 1"
class="absolute top-full left-1/2 -translate-x-1/2 w-px h-1 transition-colors duration-500"
:class="isPhaseCompleted(phaseId, idx) ? 'bg-emerald-400/30' : 'bg-white/5'"
/>
</div>
<!-- Label -->
<span class="text-sm truncate transition-colors duration-300"
:class="isPhaseActive(phaseId) ? 'text-cyan-200 font-medium' : isPhaseCompleted(phaseId, idx) ? 'text-emerald-300/70' : 'text-white/30'"
>
{{ getPhaseLabel(phaseId) }}
</span>
</button>
</div>
</div>
<!-- SimBridge Conditions Panel -->
<div
v-if="engine.autoAdvanceEnabled.value && currentPhase?.simConditions"
class="border-t border-white/5 p-4 shrink-0"
>
<div class="flex items-center gap-2 mb-3">
<v-icon icon="mdi-connection" size="16" class="text-cyan-400/70" />
<span class="text-[10px] uppercase tracking-widest text-white/30">SimBridge</span>
<div v-if="engine.currentTelemetry.value" class="ml-auto h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
<div v-else class="ml-auto h-2 w-2 rounded-full bg-red-400/50" />
</div>
<div class="space-y-2">
<div
v-for="(cond, idx) in currentPhase.simConditions.conditions"
:key="idx"
class="rounded-lg bg-white/5 p-2"
>
<div class="flex items-center justify-between mb-1">
<span class="text-[11px] text-white/50">{{ conditionLabels[cond.variable] || cond.variable }}</span>
<span class="text-[11px] font-mono" :class="isConditionMet(cond) ? 'text-emerald-300' : 'text-white/40'">
{{ getConditionIcon(cond) }}
</span>
</div>
<!-- Value bar -->
<div v-if="typeof cond.value === 'number'" class="relative h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="absolute inset-y-0 left-0 rounded-full transition-all duration-500"
:class="isConditionMet(cond) ? 'bg-emerald-400' : 'bg-cyan-500/70'"
:style="{ width: `${getConditionProgress(cond)}%` }"
/>
<!-- Target marker -->
<div
class="absolute top-0 bottom-0 w-0.5 bg-white/30"
:style="{ left: `${getTargetPosition(cond)}%` }"
/>
</div>
<!-- Boolean indicator -->
<div v-else class="flex items-center gap-1.5">
<div class="h-2 w-2 rounded-full" :class="isConditionMet(cond) ? 'bg-emerald-400' : 'bg-white/20'" />
<span class="text-[10px]" :class="isConditionMet(cond) ? 'text-emerald-300' : 'text-white/30'">
{{ getCurrentBooleanLabel(cond) }}
</span>
</div>
<!-- Current / Target -->
<div v-if="typeof cond.value === 'number'" class="flex items-center justify-between mt-1">
<span class="text-[10px] font-mono text-white/40">
{{ getCurrentValue(cond) }}{{ conditionUnits[cond.variable] ? ' ' + conditionUnits[cond.variable] : '' }}
</span>
<span class="text-[10px] text-white/30">
Ziel: {{ cond.operator }} {{ cond.value }}{{ conditionUnits[cond.variable] ? ' ' + conditionUnits[cond.variable] : '' }}
</span>
</div>
</div>
</div>
<!-- Logic indicator -->
<p class="text-[10px] text-white/20 mt-2 text-center">
{{ currentPhase.simConditions.logic === 'AND' ? 'Alle Bedingungen müssen erfüllt sein' : 'Eine Bedingung reicht' }}
</p>
</div>
</aside>
</Transition>
<!-- Header Bar -->
<header class="shrink-0 border-b border-white/5 bg-[#070d1a]/90 backdrop-blur px-4 py-3 mt-1 relative z-20">
<div class="mx-auto flex max-w-screen-lg items-center justify-between gap-3">
<!-- Left: Back + Title -->
<div class="flex items-center gap-3">
@@ -14,17 +164,6 @@
</div>
</div>
<!-- Center: Progress -->
<div class="hidden sm:flex items-center gap-2 flex-1 max-w-xs mx-4">
<div class="h-1.5 flex-1 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-cyan-500 to-cyan-300 transition-all duration-700 ease-out"
:style="{ width: `${engine.progress.value}%` }"
/>
</div>
<span class="text-xs text-white/40 tabular-nums">{{ engine.progress.value }}%</span>
</div>
<!-- Right: Auto-Advance Toggle + Session + Controls -->
<div class="flex items-center gap-2">
<!-- Auto-Advance Toggle -->
@@ -68,6 +207,17 @@
</v-btn>
</div>
<!-- Sidebar toggle (mobile) -->
<v-btn
icon
size="x-small"
variant="text"
class="text-white/30 hover:text-white/70 sm:hidden"
@click="sidebarOpen = !sidebarOpen"
>
<v-icon icon="mdi-menu" size="18" />
</v-btn>
<!-- Restart -->
<v-btn
icon
@@ -80,146 +230,144 @@
</v-btn>
</div>
</div>
<!-- Mobile progress -->
<div class="sm:hidden mt-2">
<div class="h-1 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-cyan-500 to-cyan-300 transition-all duration-700"
:style="{ width: `${engine.progress.value}%` }"
/>
</div>
</div>
</header>
<!-- SimCondition Status Bar -->
<div
v-if="engine.autoAdvanceEnabled.value && engine.hasSimConditions.value"
class="shrink-0 border-b border-white/5 bg-[#0b1328]/60 px-4 py-2"
>
<div class="mx-auto max-w-screen-lg flex items-center gap-2">
<template v-if="engine.conditionsMet.value">
<v-icon icon="mdi-check-circle" size="16" class="text-emerald-400" />
<span class="text-xs text-emerald-300">Bedingungen erfüllt</span>
</template>
<template v-else>
<v-progress-circular indeterminate size="14" width="2" color="cyan" />
<span class="text-xs text-white/40">Warte auf Sim-Bedingungen...</span>
</template>
</div>
</div>
<!-- Main Content -->
<main class="flex-1 flex flex-col justify-center px-4 py-6 sm:py-10">
<!-- Main Content -->
<main class="flex-1 flex flex-col justify-center px-4 py-6 sm:py-10 transition-all duration-300" :class="{ 'sm:ml-[300px]': sidebarOpen }">
<div class="mx-auto w-full max-w-screen-sm space-y-6">
<!-- Paused overlay -->
<div v-if="engine.isPaused.value" class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4 text-center">
<v-icon icon="mdi-pause-circle" size="24" class="text-amber-300 mb-1" />
<p class="text-sm text-amber-200">Pausiert - der Instructor setzt gleich fort</p>
</div>
<Transition name="fade-slide">
<div v-if="engine.isPaused.value" class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4 text-center">
<v-icon icon="mdi-pause-circle" size="24" class="text-amber-300 mb-1" />
<p class="text-sm text-amber-200">Pausiert - der Instructor setzt gleich fort</p>
</div>
</Transition>
<!-- Help Message Banner -->
<div
v-if="engine.showingHelpMessage.value && engine.helpMessageText.value"
class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4"
>
<div class="flex items-start gap-3">
<v-icon icon="mdi-lightbulb" size="22" class="text-amber-300 mt-0.5 shrink-0" />
<div class="flex-1">
<p class="text-sm text-amber-200 leading-relaxed">{{ engine.helpMessageText.value }}</p>
<Transition name="fade-slide">
<div
v-if="engine.showingHelpMessage.value && engine.helpMessageText.value"
class="rounded-2xl border border-amber-400/30 bg-amber-500/10 p-4"
>
<div class="flex items-start gap-3">
<v-icon icon="mdi-lightbulb" size="22" class="text-amber-300 mt-0.5 shrink-0" />
<div class="flex-1">
<p class="text-sm text-amber-200 leading-relaxed">{{ engine.helpMessageText.value }}</p>
<v-btn
size="small"
variant="text"
color="amber"
class="mt-2 -ml-2"
@click="engine.dismissHelpMessage()"
>
Verstanden
</v-btn>
</div>
</div>
</div>
</Transition>
<!-- ATC Message Area -->
<Transition name="phase-enter" mode="out-in">
<div
v-if="currentPhase"
:key="engine.currentPhaseId.value"
class="rounded-3xl border border-white/10 bg-[#0b1328]/90 p-6 sm:p-8 shadow-xl shadow-cyan-500/5"
>
<!-- Voice Animation + Skip Button -->
<div v-if="audio.isSpeaking.value" class="mb-4 flex items-center gap-2">
<div class="voice-bars flex items-end gap-[3px]">
<span v-for="i in 8" :key="i" class="voice-bar" :style="{ animationDelay: `${(i - 1) * 80}ms` }" />
</div>
<span class="text-xs text-cyan-300/70">Spricht...</span>
<v-spacer />
<!-- Skip Speech Button -->
<button
class="skip-btn flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-3 py-1 text-xs text-white/50 hover:text-white/80 hover:bg-white/10 hover:border-white/20 transition-all duration-200"
@click="audio.skipSpeech()"
>
<v-icon icon="mdi-skip-next" size="14" />
<span>Skip</span>
</button>
</div>
<!-- Phase progress bar (TTS duration indicator) -->
<div v-if="audio.isSpeaking.value" class="mb-4 h-0.5 rounded-full bg-white/5 overflow-hidden">
<div class="h-full bg-cyan-400/50 rounded-full phase-progress-animate" />
</div>
<!-- ATC text -->
<p class="text-lg sm:text-xl leading-relaxed text-white/90 font-light">
{{ currentPhase.atcMessage }}
</p>
<!-- Replay + Details row -->
<div class="mt-4 flex items-center gap-2 border-t border-white/5 pt-4">
<!-- Replay button -->
<v-btn
v-if="audio.canReplay.value"
size="small"
variant="text"
color="amber"
class="mt-2 -ml-2"
@click="engine.dismissHelpMessage()"
color="cyan"
prepend-icon="mdi-replay"
class="-ml-2"
:disabled="audio.isSpeaking.value"
@click="audio.replayLastMessage()"
>
Verstanden
Nochmal anhören
</v-btn>
<v-spacer />
<!-- More details button -->
<v-btn
v-if="currentPhase.explanation || currentPhase.simConditions || currentPhase.instructorNote"
size="small"
variant="text"
class="text-white/40 -mr-2"
prepend-icon="mdi-information-outline"
@click="showDetails = true"
>
Mehr Details
</v-btn>
</div>
</div>
</div>
<!-- ATC Message Area -->
<div
v-if="currentPhase"
class="rounded-3xl border border-white/10 bg-[#0b1328]/90 p-6 sm:p-8 shadow-xl shadow-cyan-500/5"
>
<!-- Voice Animation -->
<div v-if="audio.isSpeaking.value" class="mb-4 flex items-center gap-2">
<div class="voice-bars flex items-end gap-[3px]">
<span v-for="i in 8" :key="i" class="voice-bar" :style="{ animationDelay: `${(i - 1) * 80}ms` }" />
</div>
<span class="text-xs text-cyan-300/70">Spricht...</span>
</div>
<!-- ATC text -->
<p class="text-lg sm:text-xl leading-relaxed text-white/90 font-light">
{{ currentPhase.atcMessage }}
</p>
<!-- Replay + Details row -->
<div class="mt-4 flex items-center gap-2 border-t border-white/5 pt-4">
<!-- Replay button -->
<v-btn
v-if="audio.canReplay.value"
size="small"
variant="text"
color="cyan"
prepend-icon="mdi-replay"
class="-ml-2"
:disabled="audio.isSpeaking.value"
@click="audio.replayLastMessage()"
>
Nochmal anhören
</v-btn>
<v-spacer />
<!-- More details button -->
<v-btn
v-if="currentPhase.explanation || currentPhase.simConditions || currentPhase.instructorNote"
size="small"
variant="text"
class="text-white/40 -mr-2"
prepend-icon="mdi-information-outline"
@click="showDetails = true"
>
Mehr Details
</v-btn>
</div>
</div>
</Transition>
<!-- End screen -->
<div v-if="engine.isFinished.value" class="text-center py-4">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full border border-emerald-400/40 bg-emerald-500/10 mb-4">
<v-icon icon="mdi-check-circle" size="36" class="text-emerald-300" />
<Transition name="fade-scale">
<div v-if="engine.isFinished.value" class="text-center py-4">
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full border border-emerald-400/40 bg-emerald-500/10 mb-4 finished-icon">
<v-icon icon="mdi-check-circle" size="36" class="text-emerald-300" />
</div>
<p class="text-lg font-semibold text-emerald-200">Geschafft!</p>
</div>
<p class="text-lg font-semibold text-emerald-200">Geschafft!</p>
</div>
</Transition>
<!-- Buttons -->
<div v-if="currentPhase" class="flex flex-col gap-3">
<v-btn
v-for="btn in currentPhase.buttons"
:key="btn.id"
:color="getButtonColor(btn.type)"
:variant="btn.type === 'primary' ? 'flat' : 'outlined'"
size="large"
block
class="rounded-xl text-left justify-start min-h-[56px]"
:class="{ 'border-white/10': btn.type !== 'primary' }"
:disabled="engine.isPaused.value || audio.isSpeaking.value"
:prepend-icon="btn.icon"
@click="handleButtonPress(btn)"
>
<span class="text-sm sm:text-base">{{ btn.label }}</span>
</v-btn>
<TransitionGroup name="btn-list">
<v-btn
v-for="btn in currentPhase.buttons"
:key="btn.id"
:color="getButtonColor(btn.type)"
:variant="btn.type === 'primary' ? 'flat' : 'outlined'"
size="large"
block
class="rounded-xl text-left justify-start min-h-[56px]"
:class="{ 'border-white/10': btn.type !== 'primary' }"
:disabled="engine.isPaused.value || audio.isSpeaking.value"
:prepend-icon="btn.icon"
@click="handleButtonPress(btn)"
>
<span class="text-sm sm:text-base">{{ btn.label }}</span>
</v-btn>
</TransitionGroup>
</div>
</div>
</main>
<!-- Details Dialog -->
<!-- Details Dialog -->
<v-dialog v-model="showDetails" max-width="440">
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
<v-card-title class="text-base font-semibold pt-5 px-5 flex items-center gap-2">
@@ -264,7 +412,7 @@
</v-card>
</v-dialog>
<!-- Restart confirmation dialog -->
<!-- Restart confirmation dialog -->
<v-dialog v-model="showRestartConfirm" max-width="340">
<v-card class="rounded-2xl bg-[#0b1328] border border-white/10">
<v-card-title class="text-base font-semibold pt-5 px-5">Nochmal von vorne?</v-card-title>
@@ -287,21 +435,27 @@ import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
import { useFlightLabAudio } from '~~/shared/composables/flightlab/useFlightLabAudio'
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
import type { FlightLabButton, SimCondition } from '~~/shared/data/flightlab/types'
import type { FlightLabButton, SimCondition, FlightLabTelemetryState } from '~~/shared/data/flightlab/types'
import { useAuthStore } from '~/stores/auth'
definePageMeta({ layout: false })
definePageMeta({ layout: false, middleware: ['require-auth'] })
useHead({ title: 'FlightLab - Dein erster Start' })
const engine = useFlightLabEngine(takeoffEddf)
const audio = useFlightLabAudio()
const sync = useFlightLabSync()
const authStore = useAuthStore()
const joinCode = ref('')
const showRestartConfirm = ref(false)
const showDetails = ref(false)
const sidebarOpen = ref(false)
const currentPhase = computed(() => engine.currentPhase.value)
// Main phase IDs for sidebar stepper
const mainPhaseIds = ['welcome', 'briefing', 'runway', 'engines_pre', 'engines_spool', 'takeoff_roll', 'rotation', 'gear_retract', 'climb', 'climb_high', 'leveloff', 'debrief', 'end']
// Collect all unique sound IDs for preloading
const allSoundIds = computed(() => {
const ids = new Set<string>()
@@ -313,28 +467,96 @@ const allSoundIds = computed(() => {
return [...ids]
})
// --- Condition formatting for Details dialog ---
// --- Phase Stepper Helpers ---
function getPhaseLabel(id: string): string {
const labels: Record<string, string> = {
welcome: 'Willkommen',
briefing: 'Briefing',
runway: 'Startbahn',
engines_pre: 'Triebwerke (Vorb.)',
engines_spool: 'Triebwerke (Schub)',
takeoff_roll: 'Startlauf',
rotation: 'Abheben',
gear_retract: 'Fahrwerk ein',
climb: 'Steigflug',
climb_high: 'Steigflug (hoch)',
leveloff: 'Level-off',
debrief: 'Nachbesprechung',
end: 'Ende',
}
return labels[id] || id
}
function isPhaseActive(phaseId: string): boolean {
// Check if we're on this main phase or one of its sub-phases
if (engine.currentPhaseId.value === phaseId) return true
// Check if current phase is a sub-phase of this main phase
const idx = mainPhaseIds.indexOf(phaseId)
const nextMain = mainPhaseIds[idx + 1]
if (idx >= 0 && !mainPhaseIds.includes(engine.currentPhaseId.value)) {
// We're in a sub-phase — find closest main phase from history
for (let i = engine.history.value.length - 1; i >= 0; i--) {
const entry = engine.history.value[i]
if (entry && mainPhaseIds.includes(entry.phaseId)) {
return entry.phaseId === phaseId
}
}
}
return false
}
function isPhaseCompleted(phaseId: string, idx: number): boolean {
const currentIdx = mainPhaseIds.indexOf(engine.currentPhaseId.value)
if (currentIdx > idx) return true
// If we're in a sub-phase, check from history
if (currentIdx === -1) {
for (let i = engine.history.value.length - 1; i >= 0; i--) {
const entry = engine.history.value[i]
if (entry && mainPhaseIds.includes(entry.phaseId)) {
return mainPhaseIds.indexOf(entry.phaseId) > idx
}
}
}
return false
}
function getPhaseStepClass(phaseId: string, idx: number): string {
if (isPhaseActive(phaseId)) return 'bg-cyan-500/10 border border-cyan-400/20'
if (isPhaseCompleted(phaseId, idx)) return 'hover:bg-emerald-500/5'
return 'opacity-50 hover:opacity-70'
}
function handlePhaseJump(phaseId: string) {
engine.goToPhase(phaseId)
if (sync.isConnected.value) {
sync.sendParticipantAction(engine.currentPhaseId.value, 'jump', phaseId)
}
}
// --- SimBridge Condition Helpers ---
const conditionLabels: Record<string, string> = {
AIRSPEED_INDICATED: 'Geschwindigkeit',
GROUND_VELOCITY: 'Bodengeschwindigkeit',
GROUND_VELOCITY: 'Bodengeschw.',
VERTICAL_SPEED: 'Steigrate',
PLANE_ALTITUDE: 'Höhe',
PLANE_PITCH_DEGREES: 'Neigung',
TURB_ENG_N1_1: 'Triebwerk 1 (N1)',
TURB_ENG_N1_2: 'Triebwerk 2 (N1)',
TURB_ENG_N1_1: 'Engine 1 N1',
TURB_ENG_N1_2: 'Engine 2 N1',
SIM_ON_GROUND: 'Am Boden',
GEAR_HANDLE_POSITION: 'Fahrwerk',
FLAPS_HANDLE_INDEX: 'Klappen-Stufe',
FLAPS_HANDLE_INDEX: 'Klappen',
BRAKE_PARKING_POSITION: 'Parkbremse',
AUTOPILOT_MASTER: 'Autopilot',
}
const conditionUnits: Record<string, string> = {
AIRSPEED_INDICATED: 'Knoten',
GROUND_VELOCITY: 'Knoten',
VERTICAL_SPEED: 'ft/min',
PLANE_ALTITUDE: 'Fuß',
PLANE_PITCH_DEGREES: 'Grad',
AIRSPEED_INDICATED: 'kts',
GROUND_VELOCITY: 'kts',
VERTICAL_SPEED: 'fpm',
PLANE_ALTITUDE: 'ft',
PLANE_PITCH_DEGREES: '°',
TURB_ENG_N1_1: '%',
TURB_ENG_N1_2: '%',
FLAPS_HANDLE_INDEX: '',
@@ -353,23 +575,71 @@ function formatCondition(cond: SimCondition): string {
const label = conditionLabels[cond.variable] || cond.variable
const op = operatorLabels[cond.operator] || cond.operator
const unit = conditionUnits[cond.variable] || ''
if (typeof cond.value === 'boolean') {
if (cond.variable === 'SIM_ON_GROUND') {
return cond.value ? `${label}: Ja` : `${label}: Nein (in der Luft)`
}
if (cond.variable === 'GEAR_HANDLE_POSITION') {
return cond.value ? `${label}: Ausgefahren` : `${label}: Eingefahren`
}
if (cond.variable === 'BRAKE_PARKING_POSITION') {
return cond.value ? `${label}: Angezogen` : `${label}: Gelöst`
}
if (cond.variable === 'SIM_ON_GROUND') return cond.value ? `${label}: Ja` : `${label}: Nein (in der Luft)`
if (cond.variable === 'GEAR_HANDLE_POSITION') return cond.value ? `${label}: Ausgefahren` : `${label}: Eingefahren`
if (cond.variable === 'BRAKE_PARKING_POSITION') return cond.value ? `${label}: Angezogen` : `${label}: Gelöst`
return `${label}: ${cond.value ? 'An' : 'Aus'}`
}
return `${label} ${op} ${cond.value}${unit ? ' ' + unit : ''}`
}
function getCurrentValue(cond: SimCondition): string {
const telemetry = engine.currentTelemetry.value
if (!telemetry) return '—'
const val = telemetry[cond.variable]
if (typeof val === 'number') return val.toFixed(typeof cond.value === 'number' && cond.value % 1 === 0 ? 0 : 1)
if (typeof val === 'boolean') return val ? 'Ja' : 'Nein'
return '—'
}
function getCurrentBooleanLabel(cond: SimCondition): string {
const telemetry = engine.currentTelemetry.value
if (!telemetry) return 'Keine Daten'
const val = telemetry[cond.variable]
if (cond.variable === 'BRAKE_PARKING_POSITION') return val ? 'Angezogen' : 'Gelöst'
if (cond.variable === 'GEAR_HANDLE_POSITION') return val ? 'Ausgefahren' : 'Eingefahren'
if (cond.variable === 'SIM_ON_GROUND') return val ? 'Am Boden' : 'In der Luft'
return val ? 'An' : 'Aus'
}
function isConditionMet(cond: SimCondition): boolean {
const telemetry = engine.currentTelemetry.value
if (!telemetry) return false
const actual = telemetry[cond.variable]
const expected = cond.value
switch (cond.operator) {
case '>': return (actual as number) > (expected as number)
case '<': return (actual as number) < (expected as number)
case '>=': return (actual as number) >= (expected as number)
case '<=': return (actual as number) <= (expected as number)
case '==': return actual === expected
case '!=': return actual !== expected
default: return false
}
}
function getConditionIcon(cond: SimCondition): string {
return isConditionMet(cond) ? '✓' : '✗'
}
function getConditionProgress(cond: SimCondition): number {
if (typeof cond.value !== 'number') return 0
const telemetry = engine.currentTelemetry.value
if (!telemetry) return 0
const actual = telemetry[cond.variable] as number
if (typeof actual !== 'number') return 0
// Compute progress as ratio toward target
const target = cond.value
if (target === 0) return actual === 0 ? 100 : 0
return Math.min(100, Math.max(0, (actual / target) * 100))
}
function getTargetPosition(cond: SimCondition): number {
// Where the target marker sits (always at 100% for simplicity since bar shows progress toward target)
return 100
}
// --- Button handling ---
function getButtonColor(type?: string) {
@@ -383,10 +653,7 @@ function getButtonColor(type?: string) {
function handleButtonPress(btn: FlightLabButton) {
if (engine.isPaused.value || audio.isSpeaking.value) return
engine.selectOption(btn)
// Sync with instructor if connected
if (sync.isConnected.value) {
sync.sendParticipantAction(engine.currentPhaseId.value, btn.id, btn.next)
}
@@ -396,6 +663,10 @@ async function handleJoin() {
if (joinCode.value.length !== 4) return
try {
await sync.joinSession(joinCode.value)
// Subscribe to telemetry after joining
if (authStore.user?.id) {
sync.subscribeTelemetry(authStore.user.id)
}
} catch (e) {
console.error('[FlightLab] Join failed:', e)
}
@@ -421,18 +692,19 @@ watch(() => engine.currentPhaseId.value, async (newId, oldId) => {
if (!newId || newId === oldId) return
const phase = engine.currentPhase.value
if (!phase) return
// Handle sounds for this phase
if (phase.sounds && phase.sounds.length > 0) {
audio.handlePhaseSounds(phase.sounds)
}
// Speak ATC message
if (phase.atcMessage) {
await audio.speakAtcMessage(phase.atcMessage, { speed: 0.9, readability: 5 })
}
})
// Listen for telemetry from WebSocket (bridge → server → here)
sync.onTelemetry((data: any) => {
engine.updateTelemetry(data as FlightLabTelemetryState)
})
// Listen for instructor commands via WebSocket
sync.onStateChange((state) => {
if (state.currentPhaseId !== engine.currentPhaseId.value) {
@@ -452,13 +724,11 @@ sync.onError((msg) => {
})
onMounted(async () => {
// Preload cockpit sounds
await audio.preloadSounds(allSoundIds.value)
// Speak initial welcome
const phase = engine.currentPhase.value
if (phase?.atcMessage) {
// Small delay to let audio context init after user gesture
setTimeout(() => {
audio.speakAtcMessage(phase.atcMessage, { speed: 0.9, readability: 5 })
}, 500)
@@ -473,7 +743,100 @@ onBeforeUnmount(() => {
</script>
<style scoped>
/* Voice animation bars */
/* ═══════ Global progress glow ═══════ */
.global-progress-glow {
box-shadow: 0 0 12px rgba(34, 211, 238, 0.4), 0 0 4px rgba(34, 211, 238, 0.2);
}
/* ═══════ Sidebar transition ═══════ */
.sidebar-enter-active,
.sidebar-leave-active {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-enter-from,
.sidebar-leave-to {
transform: translateX(-100%);
}
.sidebar-scroll::-webkit-scrollbar {
width: 4px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
/* ═══════ Phase enter transition ═══════ */
.phase-enter-enter-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.phase-enter-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.phase-enter-enter-from {
opacity: 0;
transform: translateY(16px) scale(0.98);
}
.phase-enter-leave-to {
opacity: 0;
transform: translateY(-8px) scale(0.98);
}
/* ═══════ Fade slide transition ═══════ */
.fade-slide-enter-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-slide-leave-active {
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* ═══════ Fade scale transition ═══════ */
.fade-scale-enter-active {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.fade-scale-leave-active {
transition: all 0.2s ease;
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(0.8);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.9);
}
/* ═══════ Button list transition ═══════ */
.btn-list-enter-active {
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.btn-list-leave-active {
transition: all 0.2s ease;
}
.btn-list-enter-from {
opacity: 0;
transform: translateX(-12px);
}
.btn-list-leave-to {
opacity: 0;
transform: translateX(12px);
}
.btn-list-move {
transition: transform 0.3s ease;
}
/* ═══════ Voice animation bars ═══════ */
.voice-bars {
height: 24px;
}
@@ -498,7 +861,39 @@ onBeforeUnmount(() => {
100% { transform: scaleY(1); opacity: 1; }
}
/* Auto-advance toggle compact styling */
/* ═══════ Phase progress animation ═══════ */
.phase-progress-animate {
animation: phase-progress 8s linear forwards;
}
@keyframes phase-progress {
from { width: 0%; }
to { width: 100%; }
}
/* ═══════ Phase check enter animation ═══════ */
.phase-check-enter {
animation: check-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes check-pop {
0% { transform: scale(0); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* ═══════ Finished icon animation ═══════ */
.finished-icon {
animation: finished-pulse 2s ease-in-out infinite;
}
@keyframes finished-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(52, 211, 153, 0.3); }
50% { box-shadow: 0 0 20px 8px rgba(52, 211, 153, 0.15); }
}
/* ═══════ Skip button hover ═══════ */
.skip-btn:hover {
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
/* ═══════ Auto-advance toggle compact styling ═══════ */
.auto-advance-toggle :deep(.v-switch__track) {
height: 18px;
min-width: 32px;

View File

@@ -253,9 +253,12 @@ import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
import { useFlightLabEngine } from '~~/shared/composables/flightlab/useFlightLabEngine'
import { useFlightLabSync } from '~~/shared/composables/flightlab/useFlightLabSync'
definePageMeta({ layout: false })
definePageMeta({ layout: false, middleware: ['require-auth'] })
useHead({ title: 'FlightLab - Instructor Panel' })
// Guard: skip all logic on server
if (!import.meta.client) throw new Error('Client-only page')
const engine = useFlightLabEngine(takeoffEddf)
const sync = useFlightLabSync()

View File

@@ -1,12 +1,56 @@
import { defineEventHandler, readBody } from 'h3'
import { defineEventHandler, readBody, getHeader } from 'h3'
import { resolveUserFromToken } from '../../utils/auth'
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
/**
* Receives MSFS SimConnect telemetry data from an external bridge application.
*
* The bridge should POST telemetry data with an Authorization header so we
* can route the data to the correct FlightLab WebSocket session.
*
* ──────────────────────────────────────────
* EXAMPLE REQUEST (from SimBridge app):
* ──────────────────────────────────────────
*
* POST /api/bridge/data
* Authorization: Bearer <user-jwt-token>
* Content-Type: application/json
*
* {
* "AIRSPEED_INDICATED": 145.2,
* "GROUND_VELOCITY": 142.8,
* "VERTICAL_SPEED": 0,
* "PLANE_ALTITUDE": 364,
* "PLANE_PITCH_DEGREES": 0.5,
* "TURB_ENG_N1_1": 87.3,
* "TURB_ENG_N1_2": 86.9,
* "SIM_ON_GROUND": true,
* "GEAR_HANDLE_POSITION": true,
* "FLAPS_HANDLE_INDEX": 2,
* "BRAKE_PARKING_POSITION": false,
* "AUTOPILOT_MASTER": false
* }
*
* ──────────────────────────────────────────
* RESPONSE: 204 No Content (success)
* 401 Unauthorized (missing/invalid token)
* ──────────────────────────────────────────
*/
export default defineEventHandler(async (event) => {
// Body einlesen
const body = await readBody(event)
// Resolve user from Bearer token (optional — also works with ?userId query param)
const user = await resolveUserFromToken(event)
const userId = user?._id?.toString()
?? new URL(event.node.req.url ?? '', 'http://localhost').searchParams.get('userId')
// Alles in der Server-Konsole ausgeben
console.log('MSFS Telemetry:', body)
if (!userId) {
event.node.res.statusCode = 401
return { error: 'Authorization required — send Bearer token or ?userId query param' }
}
// Leere 204-Antwort zurückgeben
event.node.res.statusCode = 204
const body = await readBody(event)
// Store telemetry and broadcast to WebSocket subscribers
flightlabTelemetryStore.update(userId, body)
event.node.res.statusCode = 204
})

View File

@@ -1,5 +1,6 @@
// server/api/flightlab/ws.ts
import { defineWebSocketHandler } from 'h3'
import { flightlabTelemetryStore } from '../../utils/flightlabTelemetry'
interface FlightLabSession {
code: string
@@ -10,11 +11,25 @@ interface FlightLabSession {
participantConnected: boolean
instructorConnected: boolean
history: Array<{ phaseId: string; buttonId: string; timestamp: number }>
peers: Map<string, { role: 'instructor' | 'participant'; peer: any }>
peers: Map<string, { role: 'instructor' | 'participant'; peer: any; userId?: string }>
/** User IDs subscribed to telemetry for this session */
telemetryUserIds: Set<string>
}
const sessions = new Map<string, FlightLabSession>()
// Map userId → session code for telemetry routing
const userSessionMap = new Map<string, string>()
// Subscribe to telemetry store updates and relay to WebSocket clients
flightlabTelemetryStore.subscribe((userId, data) => {
const sessionCode = userSessionMap.get(userId)
if (!sessionCode) return
const session = sessions.get(sessionCode)
if (!session) return
broadcastToSession(session, { type: 'telemetry', data })
})
function generateCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No I,O,0,1 for readability
let code = ''
@@ -77,6 +92,7 @@ export default defineWebSocketHandler({
instructorConnected: true,
history: [],
peers: new Map([[peerId, { role: 'instructor', peer }]]),
telemetryUserIds: new Set(),
}
sessions.set(code, session)
peer.send(JSON.stringify({ type: 'session-created', code, state: getSessionState(session) }))
@@ -147,6 +163,17 @@ export default defineWebSocketHandler({
}, peerId)
break
}
case 'subscribe-telemetry': {
// Client sends their userId so bridge data gets routed to their session
const session = findSessionByPeer(peerId)
if (!session || !data.userId) return
const peerInfo = session.peers.get(peerId)
if (peerInfo) peerInfo.userId = data.userId
session.telemetryUserIds.add(data.userId)
userSessionMap.set(data.userId, session.code)
break
}
}
},
@@ -155,6 +182,11 @@ export default defineWebSocketHandler({
for (const [code, session] of sessions) {
const peerInfo = session.peers.get(peerId)
if (peerInfo) {
// Clean up telemetry subscription
if (peerInfo.userId) {
session.telemetryUserIds.delete(peerInfo.userId)
userSessionMap.delete(peerInfo.userId)
}
session.peers.delete(peerId)
if (peerInfo.role === 'participant') session.participantConnected = false
if (peerInfo.role === 'instructor') session.instructorConnected = false

View File

@@ -0,0 +1,35 @@
/**
* In-memory telemetry store that bridges the HTTP POST endpoint
* with FlightLab WebSocket sessions.
*
* Flow: SimBridge → POST /api/bridge/data → telemetryStore → WS → Client
*/
type TelemetryListener = (userId: string, data: any) => void
class FlightLabTelemetryStore {
/** Latest telemetry per userId */
private data = new Map<string, any>()
/** WebSocket listeners that get notified on new data */
private listeners = new Set<TelemetryListener>()
update(userId: string, telemetry: any) {
this.data.set(userId, { ...telemetry, timestamp: Date.now() })
// Notify all listeners (WebSocket handler subscribes here)
for (const listener of this.listeners) {
try { listener(userId, this.data.get(userId)) } catch {}
}
}
get(userId: string) {
return this.data.get(userId) ?? null
}
subscribe(listener: TelemetryListener) {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
}
// Singleton — shared across all server handlers
export const flightlabTelemetryStore = new FlightLabTelemetryStore()

View File

@@ -12,6 +12,7 @@ export function useFlightLabAudio() {
const soundBuffers = ref<Map<string, AudioBuffer>>(new Map())
let speechQueue: Promise<void> = Promise.resolve()
let pizzicato: PizzicatoLiteType | null = null
let currentSpeechReject: (() => void) | null = null
// --- Replay Cache ---
const lastSpokenAudio = ref<{ base64: string; mime: string; readability: number; text: string } | null>(null)
@@ -151,6 +152,9 @@ export function useFlightLabAudio() {
})
}
let currentPizzicatoSound: any = null
let currentNoiseStoppers: Array<() => void> = []
async function playWithRadioEffects(base64: string, _mime: string, readability: number) {
const pz = await loadPizzicato()
if (!pz) return
@@ -158,6 +162,7 @@ export function useFlightLabAudio() {
const profile = getReadabilityProfile(readability)
const sound = await pz.createSoundFromBase64(ctx, base64)
currentPizzicatoSound = sound
// Apply radio filter chain
sound.addEffect(new pz.Effects.HighPassFilter(ctx, { frequency: profile.eq.highpass, q: profile.eq.highpassQ }))
@@ -185,9 +190,28 @@ export function useFlightLabAudio() {
sound.setVolume(profile.gain)
const stopNoise = createNoiseGenerators(ctx, sound.duration, profile, readability)
currentNoiseStoppers = stopNoise
await sound.play()
stopNoise.forEach((fn: () => void) => fn())
currentPizzicatoSound = null
currentNoiseStoppers = []
}
/** Skip currently playing TTS speech immediately */
function skipSpeech() {
if (!isSpeaking.value) return
// Stop the pizzicato sound
if (currentPizzicatoSound) {
try { currentPizzicatoSound.stop() } catch {}
currentPizzicatoSound = null
}
// Stop noise generators
currentNoiseStoppers.forEach((fn) => { try { fn() } catch {} })
currentNoiseStoppers = []
isSpeaking.value = false
// Reset the speech queue so next speech can start fresh
speechQueue = Promise.resolve()
}
async function replayLastMessage(): Promise<void> {
@@ -248,6 +272,7 @@ export function useFlightLabAudio() {
crossfadeSound,
stopAllSounds,
setMasterVolume,
skipSpeech,
dispose,
}
}

View File

@@ -14,6 +14,7 @@ export function useFlightLabSync() {
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>,
}
@@ -70,6 +71,9 @@ export function useFlightLabSync() {
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
@@ -92,6 +96,11 @@ export function useFlightLabSync() {
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 })
}
@@ -117,6 +126,7 @@ export function useFlightLabSync() {
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 {
@@ -126,6 +136,7 @@ export function useFlightLabSync() {
remoteState,
createSession,
joinSession,
subscribeTelemetry,
sendParticipantAction,
sendInstructorCommand,
sendInstructorMessage,
@@ -134,6 +145,7 @@ export function useFlightLabSync() {
onInstructorMessage,
onPeerJoined,
onPeerLeft,
onTelemetry,
onError,
}
}