mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-04 16:22:48 +08:00
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:
@@ -126,6 +126,7 @@ import { takeoffEddf } from '~~/shared/data/flightlab/takeoff-eddf'
|
||||
|
||||
definePageMeta({
|
||||
layout: false,
|
||||
middleware: ['require-auth'],
|
||||
})
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
35
server/utils/flightlabTelemetry.ts
Normal file
35
server/utils/flightlabTelemetry.ts
Normal 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()
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user