Align Learn intro buttons with shared Learn styling

This commit is contained in:
Remi
2025-09-22 18:19:41 +02:00
parent 839795f6a3
commit e9f2f1a15a
4 changed files with 993 additions and 39 deletions

View File

@@ -0,0 +1,39 @@
/* Shared Learn surface styling */
.learn-theme .btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
font-weight: 600;
text-decoration: none;
}
.learn-theme .btn:hover {
background: color-mix(in srgb, var(--text) 10%, transparent);
}
.learn-theme .btn.primary {
background: linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 90%, transparent),
color-mix(in srgb, var(--accent) 70%, transparent)
);
color: #061318;
border-color: color-mix(in srgb, var(--accent) 60%, transparent);
}
.learn-theme .btn.soft {
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.learn-theme .btn.ghost {
background: transparent;
}
.learn-theme .btn.mini {
padding: 6px 10px;
font-size: 12px;
}

View File

@@ -0,0 +1,949 @@
<template>
<div class="learn-theme min-h-screen bg-[#070d1a] text-white">
<header class="border-b border-white/5 bg-[#070d1a]/80 backdrop-blur">
<div
class="mx-auto flex w-full max-w-screen-xl flex-wrap items-center justify-between gap-4 px-4 py-6 sm:px-6 md:px-8"
>
<div class="flex items-center gap-3">
<div class="flex h-12 w-12 items-center justify-center rounded-2xl border border-cyan-400/40 bg-cyan-500/10">
<v-icon icon="mdi-radar" size="26" class="text-cyan-300" />
</div>
<div>
<p class="text-lg font-semibold tracking-tight">Learn orientation</p>
<p class="text-sm text-white/60">Guided preflight briefing</p>
</div>
</div>
<NuxtLink to="/learn" class="btn primary">
Enter Learn hub
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
</NuxtLink>
</div>
</header>
<main>
<section class="relative border-b border-white/5">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-cyan-500/15 via-transparent to-indigo-500/25"></div>
<div class="relative z-10 mx-auto w-full max-w-screen-xl px-4 py-16 sm:px-6 md:px-8">
<div class="grid gap-12 lg:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] lg:items-center">
<div class="space-y-6">
<span
class="inline-flex items-center gap-2 rounded-full border border-cyan-300/40 bg-cyan-400/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.3em] text-cyan-200/80"
>Orientation</span>
<h1 class="text-3xl font-semibold leading-tight sm:text-4xl md:text-5xl">
Get comfortable with Learn before you call live AI ATC
</h1>
<p class="max-w-2xl text-base text-white/80 sm:text-lg">
This is your training playground. Live AI controllers for the simulator are still in closed testing, so we
start with Learn to build phraseology, timing and confidence. I will guide you through the flow before you
dive into the hub.
</p>
<div class="rounded-2xl border border-amber-400/40 bg-amber-400/10 p-5 text-amber-100 shadow-lg shadow-amber-500/10" role="status">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-9 w-9 items-center justify-center rounded-xl border border-amber-200/50 bg-amber-300/20">
<v-icon icon="mdi-alert-decagram-outline" size="22" class="text-amber-200" />
</div>
<div class="space-y-1">
<p class="text-sm font-semibold uppercase tracking-wide text-amber-100/90">Heads-up</p>
<p class="text-sm text-amber-50/90">
Learn mirrors the calls you will make later, but today it runs as a standalone trainer. Treat it like a
warm-up before the live network opens.
</p>
</div>
</div>
</div>
<ul class="grid gap-3 text-sm text-white/70 sm:grid-cols-2 sm:text-base">
<li class="flex items-start gap-3">
<v-icon icon="mdi-compass-outline" size="20" class="mt-1 text-cyan-300" />
<span>Follow a short, interactive tour so nothing drops on you all at once.</span>
</li>
<li class="flex items-start gap-3">
<v-icon icon="mdi-headset" size="20" class="mt-1 text-cyan-300" />
<span>Choose text only or let the instructor speak over a radio-style voice.</span>
</li>
</ul>
</div>
<div class="flex flex-col gap-6">
<div class="overflow-hidden rounded-3xl border border-white/10 bg-black/40 shadow-xl shadow-cyan-500/10">
<img
src="/img/learn/modules/img14.jpeg"
alt="Pilots reviewing the Learn mission board"
class="h-56 w-full object-cover"
loading="lazy"
/>
</div>
<div class="rounded-3xl border border-white/10 bg-[#0b1328]/90 p-6 shadow-xl shadow-cyan-500/10">
<div class="flex items-center gap-4">
<img
src="/img/logo.jpeg"
alt="Avery, the Learn instructor"
class="h-14 w-14 rounded-2xl border border-cyan-400/40 object-cover"
/>
<div>
<p class="text-base font-semibold">Avery</p>
<p class="text-sm text-white/60">Your Learn instructor</p>
</div>
</div>
<p class="mt-4 text-sm text-white/70">
Hey! I will keep the pace relaxed and fun. Pick how you want to follow along and we will walk through each
stop together.
</p>
<div class="mt-6 space-y-4">
<div>
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Delivery</p>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="btn soft voice-toggle"
:class="{ active: voiceMode === 'text' }"
@click="setVoiceMode('text')"
:aria-pressed="voiceMode === 'text'"
>
<v-icon icon="mdi-text" size="18" class="text-cyan-200" />
Text only
</button>
<button
type="button"
class="btn soft voice-toggle"
:class="{ active: voiceMode === 'radio' }"
@click="setVoiceMode('radio')"
:aria-pressed="voiceMode === 'radio'"
>
<v-icon icon="mdi-radio-handheld" size="18" class="text-cyan-200" />
Radio voice
</button>
</div>
</div>
<div
v-if="voiceMode === 'radio'"
class="space-y-4 rounded-2xl border border-cyan-400/30 bg-cyan-400/5 p-4"
>
<div>
<label
for="radio-level"
class="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-cyan-100/80"
>
Radio clarity
<span class="text-[11px] text-cyan-100/60">Level {{ radioLevel }}</span>
</label>
<input
id="radio-level"
v-model.number="radioLevel"
type="range"
min="1"
max="5"
step="1"
class="mt-2 h-1 w-full rounded-full accent-cyan-300"
/>
<p class="mt-2 text-xs text-cyan-100/70">Lower numbers add static. Higher numbers sound crisp.</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
class="btn primary"
@click="handleRadioCheck"
:disabled="speechLoading"
>
<v-icon
:icon="speechLoading ? 'mdi-loading' : 'mdi-radio-check'"
size="18"
class="text-[#061318]"
:class="{ 'animate-spin': speechLoading }"
/>
{{ speechLoading ? 'Checking…' : hasCompletedRadioCheck ? 'Radio check again' : 'Run radio check' }}
</button>
<p v-if="!hasCompletedRadioCheck" class="text-xs text-cyan-100/70">
Play a quick sample to confirm the instructor audio works.
</p>
<p v-else class="flex items-center gap-1 text-xs text-emerald-200/80">
<v-icon icon="mdi-check-circle" size="16" class="text-emerald-300" />
Loud and clear! Audio is ready.
</p>
</div>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
class="btn primary"
@click="startTour"
:disabled="startDisabled"
>
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
Start guided tour
</button>
<NuxtLink to="/learn" class="btn ghost">
Skip to Learn
<v-icon icon="mdi-arrow-right" size="16" />
</NuxtLink>
</div>
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-2 text-xs text-amber-200/80">
Run the radio check once so I know you can hear me before we roll.
</p>
<p v-if="speechError" class="mt-2 text-xs text-rose-200/80">
{{ speechError }}
</p>
</div>
</div>
</div>
</div>
</section>
<section id="orientation" class="border-b border-white/5 bg-[#080f1f]/80 py-16 sm:py-20">
<div class="mx-auto flex w-full max-w-screen-xl flex-col gap-10 px-4 sm:px-6 md:px-8 lg:flex-row">
<aside class="shrink-0 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-lg shadow-black/20 lg:w-80">
<p class="text-xs uppercase tracking-[0.3em] text-cyan-200/70">Itinerary</p>
<div class="mt-4">
<div class="flex items-center gap-3 text-xs text-white/70">
<div class="flex-1 overflow-hidden rounded-full bg-white/10">
<div
class="progress-fill"
:style="{ width: `${stageProgress}%` }"
></div>
</div>
<span class="font-semibold text-white/80">{{ stageProgress }}%</span>
</div>
<p class="mt-2 text-[13px] text-white/60">
We will stop at {{ stages.length }} stations. Follow them in order for the smoothest ride.
</p>
</div>
<ol class="mt-6 space-y-2">
<li v-for="(stop, index) in stages" :key="stop.id">
<button
type="button"
class="group flex w-full items-center gap-3 rounded-2xl border border-white/5 px-4 py-3 text-left transition hover:border-cyan-300/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-200 focus-visible:ring-offset-2 focus-visible:ring-offset-[#080f1f]"
:class="{
'border-cyan-300/60 bg-cyan-400/10 text-white': tourStarted && index === stageIndex,
'cursor-not-allowed opacity-50': index > unlockedStageIndex
}"
@click="goToStage(index)"
:disabled="index > unlockedStageIndex"
>
<div
class="flex h-8 w-8 items-center justify-center rounded-full border border-cyan-300/50 bg-cyan-500/10 text-sm font-semibold text-cyan-200"
>
{{ index + 1 }}
</div>
<div class="flex-1">
<p class="text-sm font-semibold text-white">{{ stop.title }}</p>
<p v-if="tourStarted && index === stageIndex" class="text-xs text-white/60">{{ stop.summary }}</p>
</div>
<v-icon v-if="index < stageIndex" icon="mdi-check" size="18" class="text-emerald-300" />
</button>
</li>
</ol>
<button
v-if="tourStarted"
type="button"
class="btn ghost mini mt-6"
@click="restartTour"
>
<v-icon icon="mdi-refresh" size="18" class="text-cyan-200" />
Restart tour
</button>
</aside>
<div class="flex-1">
<div
v-if="!tourStarted"
class="flex h-full flex-col justify-center rounded-3xl border border-dashed border-white/10 bg-[#0a1326]/60 p-8 text-center shadow-inner shadow-black/20"
>
<p class="text-lg font-semibold">Ready when you are</p>
<p class="mt-3 text-sm text-white/70">
Start the guided tour above. I will only show one stop at a time so it never feels overwhelming.
</p>
<div class="mt-6 flex flex-wrap justify-center gap-3">
<button
type="button"
class="btn primary"
@click="startTour"
:disabled="startDisabled"
>
<v-icon icon="mdi-gesture-tap-button" size="18" class="text-[#061318]" />
Start guided tour
</button>
</div>
<p v-if="startDisabled && voiceMode === 'radio'" class="mt-4 text-xs text-amber-200/80">
Run the radio check above to enable the instructor voice.
</p>
<p v-if="speechError" class="mt-2 text-xs text-rose-200/80">
{{ speechError }}
</p>
</div>
<Transition name="fade-slide" mode="out-in">
<article
v-if="tourStarted"
:key="activeStage.id"
class="relative overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-b from-white/5 via-white/[0.03] to-white/[0.02] shadow-xl shadow-black/30"
>
<div class="relative h-48 w-full overflow-hidden border-b border-white/5 sm:h-56">
<img :src="activeStage.image" :alt="activeStage.imageAlt" class="h-full w-full object-cover" loading="lazy" />
<div class="absolute inset-0 bg-gradient-to-t from-[#080f1f] via-transparent to-transparent"></div>
<div class="absolute bottom-4 left-4 rounded-full bg-[#080f1f]/80 px-3 py-1 text-xs font-semibold uppercase tracking-[0.3em] text-white/80">
Stop {{ stageIndex + 1 }} of {{ stages.length }}
</div>
</div>
<div class="space-y-6 px-6 py-8 sm:px-8">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-2xl font-semibold">{{ activeStage.title }}</h2>
<p class="mt-2 max-w-2xl text-sm text-white/70">
{{ activeStage.summary }}
</p>
</div>
<div class="rounded-2xl border border-cyan-300/40 bg-cyan-400/10 px-4 py-2 text-sm font-semibold text-cyan-100/90">
{{ progressLabel }}
</div>
</div>
<ul class="space-y-3 text-sm text-white/75">
<li v-for="(point, pointIndex) in activeStage.points" :key="pointIndex" class="flex items-start gap-3">
<v-icon icon="mdi-check-circle-outline" size="18" class="mt-0.5 text-cyan-300" />
<span>{{ point }}</span>
</li>
</ul>
<div v-if="activeStage.tip" class="rounded-2xl border border-emerald-300/30 bg-emerald-400/10 p-4 text-sm text-emerald-100/90">
<div class="flex items-start gap-3">
<v-icon icon="mdi-lightbulb-on-outline" size="20" class="mt-0.5 text-emerald-200" />
<div>
<p class="font-semibold text-emerald-50">Instructor tip</p>
<p class="mt-1 text-emerald-100/80">{{ activeStage.tip }}</p>
</div>
</div>
</div>
<div class="rounded-2xl border border-white/10 bg-[#0c162c]/80 p-4 sm:p-5">
<div class="flex items-start gap-4">
<img
src="/img/logo.jpeg"
alt="Avery instructor avatar"
class="h-12 w-12 rounded-2xl border border-cyan-400/40 object-cover"
/>
<div class="flex-1 space-y-3">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-semibold text-white">Avery on comms</p>
<span
v-if="voiceMode === 'radio' && speechPlaying"
class="inline-flex items-center gap-1 rounded-full bg-emerald-400/15 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-emerald-200"
>
<v-icon icon="mdi-volume-high" size="14" class="text-emerald-200" />
Speaking
</span>
</div>
<p class="text-sm text-white/70">
{{ activeStage.voiceLine }}
</p>
<div v-if="voiceMode === 'radio'" class="flex flex-wrap items-center gap-3">
<button
type="button"
class="btn soft mini voice-replay"
@click="replayStageVoice"
:disabled="speechLoading"
>
<v-icon
:icon="speechLoading ? 'mdi-loading' : 'mdi-replay'"
size="18"
class="text-cyan-200"
:class="{ 'animate-spin': speechLoading }"
/>
Replay radio voice
</button>
<p v-if="speechError" class="text-xs text-rose-200/80">{{ speechError }}</p>
</div>
</div>
</div>
</div>
<div class="border-t border-white/10 pt-6">
<div class="flex flex-wrap items-center gap-3">
<button
v-if="canGoPrevious"
type="button"
class="btn ghost"
@click="goToPrevious"
>
<v-icon icon="mdi-arrow-left" size="18" class="text-cyan-200" />
Previous stop
</button>
<div class="ml-auto flex flex-wrap items-center gap-3">
<button
v-if="voiceMode === 'radio'"
type="button"
class="btn ghost mini play-again"
@click="maybeSpeakForStage"
:disabled="speechLoading"
>
<v-icon icon="mdi-play-circle" size="16" class="text-cyan-200" />
Play again
</button>
<button
v-if="canGoNext"
type="button"
class="btn primary"
@click="goToNext"
>
Next stop
<v-icon icon="mdi-arrow-right" size="18" class="text-[#061318]" />
</button>
<NuxtLink v-else to="/learn" class="btn primary">
Enter Learn hub
<v-icon icon="mdi-launch" size="18" class="text-[#061318]" />
</NuxtLink>
</div>
</div>
</div>
</div>
</article>
</Transition>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useHead } from '#imports'
import { useApi } from '~/composables/useApi'
import type { PizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import { loadPizzicatoLite } from '~~/shared/utils/pizzicatoLite'
import { clampReadability, createNoiseGenerators, getReadabilityProfile } from '~~/shared/utils/radioEffects'
type VoiceMode = 'text' | 'radio'
interface StageStop {
id: string
title: string
summary: string
points: string[]
tip?: string
image: string
imageAlt: string
voiceLine: string
}
useHead({ title: 'Learn orientation • OpenSquawk' })
const stages: StageStop[] = [
{
id: 'hub',
title: 'Mission hub',
summary: 'Browse modules, see XP progress and decide what to train next.',
points: [
'Each tile shows remaining lessons, best scores and unlock status.',
'Use “Continue” to jump back into the last lesson you touched.'
],
tip: 'Hover or tap a tile for a quick brief before opening it.',
image: '/img/learn/modules/img7.jpeg',
imageAlt: 'Learn mission hub interface with highlighted modules',
voiceLine:
'First stop is the mission hub. This is our briefing room before any AI frequency opens. Pick the module you want and you are two clicks from a lesson.'
},
{
id: 'planning',
title: 'Plan your mission',
summary: 'Some lessons need a quick flight setup so the scenario makes sense.',
points: [
'Roll a random plan for an instant route or import SimBrief if you have one ready.',
'Confirm the callsign, runway notes and weather before starting the lesson.'
],
tip: 'Full Flight missions always start here, while most drills jump straight into the console.',
image: '/img/learn/modules/img9.jpeg',
imageAlt: 'Pilots preparing a flight plan on a tablet',
voiceLine:
'When a mission asks for a plan, choose Random, Manual or SimBrief. Try the random generator at least once—it is the fastest way to get airborne.'
},
{
id: 'console',
title: 'Lesson console',
summary: 'Play the ATC clip, give your readback and grade it when ready.',
points: [
'Press Play to hear ATC, type or speak your response and tap Check to score it.',
'Reveal hints or slow the audio whenever you need extra help.'
],
tip: 'The console saves your best answer so you can see improvement at a glance.',
image: '/img/learn/modules/img8.jpeg',
imageAlt: 'Lesson console showing ATC playback controls',
voiceLine:
'Inside the console you control the pace. Listen to the call, craft your readback and check it only when it sounds right to you.'
},
{
id: 'settings',
title: 'Practice boosters',
summary: 'Tune the challenge level whenever you want a new workout.',
points: [
'Adjust audio speed and radio clarity to simulate a quiet or busy frequency.',
'Enable Audio Challenge to hide on-screen text once you feel confident.'
],
tip: 'Drop the radio level for extra static when you want to stress-test your ears.',
image: '/img/learn/modules/img10.jpeg',
imageAlt: 'Audio settings and challenge toggles inside Learn',
voiceLine:
'Your settings live here. Slide the radio level for more or less static and flip on Audio Challenge when you want the full listen-and-respond workout.'
},
{
id: 'wrap',
title: 'Wrap-up & next steps',
summary: 'Review the scorecard and pick your next move back in the hub.',
points: [
'Every lesson ends with accuracy notes, timing feedback and retry suggestions.',
'Use the summary to decide whether to repeat, advance or swap to another module.'
],
tip: 'Bookmark /learn so a quick refresher is always a tab away.',
image: '/img/learn/modules/img11.jpeg',
imageAlt: 'Pilot reviewing a scorecard summary',
voiceLine:
'That is the full loop. Check the scorecard for anything to repeat, then head back to the hub and pick your next practice hop.'
}
]
const api = useApi()
const voiceMode = ref<VoiceMode>('text')
const radioLevel = ref(3)
const hasCompletedRadioCheck = ref(false)
const tourStarted = ref(false)
const stageIndex = ref(0)
const unlockedStageIndex = ref(0)
const speechLoading = ref(false)
const speechPlaying = ref(false)
const speechError = ref<string | null>(null)
const audioElement = ref<HTMLAudioElement | null>(null)
let speechRequestId = 0
const isClient = typeof window !== 'undefined'
let speechContext: AudioContext | null = null
let pizzicatoLiteInstance: PizzicatoLite | null = null
type RadioSoundInstance = Awaited<ReturnType<PizzicatoLite['createSoundFromBase64']>>
let activeRadioSound: RadioSoundInstance | null = null
let activeRadioCleanup: Array<() => void> = []
const activeStage = computed(() => stages[stageIndex.value])
const canGoPrevious = computed(() => stageIndex.value > 0)
const canGoNext = computed(() => stageIndex.value < stages.length - 1)
const stageProgress = computed(() => {
if (!tourStarted.value) return 0
const raw = ((stageIndex.value + 1) / stages.length) * 100
return Math.min(100, Math.max(0, Math.round(raw)))
})
const progressLabel = computed(() => {
if (!tourStarted.value) return 'Not started yet'
return `Leg ${stageIndex.value + 1} · ${stageProgress.value}% ready`
})
const startDisabled = computed(() => voiceMode.value === 'radio' && !hasCompletedRadioCheck.value)
async function ensureSpeechAudioContext(): Promise<AudioContext | null> {
if (!isClient) return null
const audioWindow = window as typeof window & { webkitAudioContext?: typeof AudioContext }
const AudioContextCtor = audioWindow.AudioContext || audioWindow.webkitAudioContext
if (!AudioContextCtor) return null
if (!speechContext || speechContext.state === 'closed') {
speechContext = new AudioContextCtor()
}
if (speechContext.state === 'suspended') {
try {
await speechContext.resume()
} catch (error) {
console.warn('Failed to resume speech audio context', error)
}
}
return speechContext
}
async function ensurePizzicato(ctx: AudioContext | null): Promise<PizzicatoLite | null> {
if (!ctx) return null
if (!pizzicatoLiteInstance) {
pizzicatoLiteInstance = await loadPizzicatoLite()
}
return pizzicatoLiteInstance
}
function releaseRadio(cleanups: Array<() => void>, sound: RadioSoundInstance | null) {
if (activeRadioSound === sound) {
activeRadioSound = null
}
if (activeRadioCleanup === cleanups) {
activeRadioCleanup = []
}
if (cleanups.length) {
cleanups.forEach(stop => {
try {
stop()
} catch {
// ignore cleanup failures
}
})
cleanups.length = 0
}
sound?.clearEffects()
}
async function playWithRadioEffect(base64: string, readability: number, requestId: number): Promise<boolean> {
let sound: RadioSoundInstance | null = null
const cleanups: Array<() => void> = []
try {
const ctx = await ensureSpeechAudioContext()
if (!ctx || speechRequestId !== requestId) return false
const pizzicato = await ensurePizzicato(ctx)
if (!pizzicato || speechRequestId !== requestId) return false
sound = await pizzicato.createSoundFromBase64(ctx, base64)
if (!sound || speechRequestId !== requestId) return false
const profile = getReadabilityProfile(readability)
const { Effects } = pizzicato
const highpass = new Effects.HighPassFilter(ctx, {
frequency: profile.eq.highpass,
q: profile.eq.highpassQ
})
const lowpass = new Effects.LowPassFilter(ctx, {
frequency: profile.eq.lowpass,
q: profile.eq.lowpassQ
})
sound.addEffect(highpass)
sound.addEffect(lowpass)
if (profile.eq.bandpass) {
sound.addEffect(
new Effects.BandPassFilter(ctx, {
frequency: profile.eq.bandpass.frequency,
q: profile.eq.bandpass.q
})
)
}
if (profile.presence) {
sound.addEffect(new Effects.PeakingFilter(ctx, profile.presence))
}
profile.distortions.forEach(amount => {
sound?.addEffect(new Effects.Distortion(ctx, { amount }))
})
sound.addEffect(new Effects.Compressor(ctx, profile.compressor))
if (profile.tremolos) {
profile.tremolos.forEach(tremolo => {
sound?.addEffect(new Effects.Tremolo(ctx, tremolo))
})
}
sound.setVolume(profile.gain)
const playbackDuration = Math.max(0.1, sound.duration)
cleanups.push(...createNoiseGenerators(ctx, playbackDuration, profile, readability))
if (speechRequestId !== requestId) {
releaseRadio(cleanups, sound)
return false
}
activeRadioSound = sound
activeRadioCleanup = cleanups
if (speechRequestId !== requestId) {
releaseRadio(cleanups, sound)
return false
}
const playbackPromise = sound.play()
if (speechRequestId === requestId) {
speechPlaying.value = true
}
playbackPromise.finally(() => {
const shouldUpdateState = speechRequestId === requestId
releaseRadio(cleanups, sound)
if (shouldUpdateState) {
speechPlaying.value = false
}
})
return true
} catch (error) {
releaseRadio(cleanups, sound)
console.error('Failed to apply radio effect', error)
return false
}
}
function stopAudio(advanceRequestId = true) {
speechPlaying.value = false
if (activeRadioSound || activeRadioCleanup.length) {
const sound = activeRadioSound
if (sound) {
try {
sound.stop()
} catch (error) {
console.warn('Failed to stop radio voice', error)
}
}
releaseRadio(activeRadioCleanup, sound ?? null)
}
if (audioElement.value) {
try {
audioElement.value.pause()
audioElement.value.currentTime = 0
} catch (error) {
console.warn('Failed to stop audio', error)
}
audioElement.value = null
}
if (advanceRequestId) {
speechRequestId += 1
}
}
async function speak(text: string, stageId?: string): Promise<boolean> {
const trimmed = text?.trim()
if (!trimmed) return false
stopAudio(false)
const requestId = ++speechRequestId
speechError.value = null
speechLoading.value = true
speechPlaying.value = false
try {
const payload: Record<string, unknown> = {
text: trimmed,
level: radioLevel.value,
speed: 1,
moduleId: 'learn-intro',
lessonId: stageId || null,
tag: 'learn-orientation'
}
const response: any = await api.post('/api/atc/say', payload)
if (requestId !== speechRequestId) {
return false
}
const base64 = response?.audio?.base64
if (!base64 || typeof base64 !== 'string') {
throw new Error('The radio voice is unavailable right now.')
}
const mime = response?.audio?.mime || 'audio/wav'
const readability = clampReadability(radioLevel.value)
const playedWithEffects = await playWithRadioEffect(base64, readability, requestId)
if (requestId !== speechRequestId) {
return false
}
if (playedWithEffects) {
return true
}
const audio = new Audio(`data:${mime};base64,${base64}`)
audio.onended = () => {
if (speechRequestId === requestId) {
speechPlaying.value = false
if (audioElement.value === audio) {
audioElement.value = null
}
}
}
audio.onerror = () => {
if (speechRequestId === requestId) {
speechPlaying.value = false
speechError.value = 'Playback failed. Check your audio output and try again.'
if (audioElement.value === audio) {
audioElement.value = null
}
}
}
audioElement.value = audio
try {
await audio.play()
if (speechRequestId === requestId) {
speechPlaying.value = true
}
return true
} catch (error) {
if (speechRequestId === requestId) {
speechError.value = 'Audio was blocked by the browser. Click anywhere on the page and try again.'
speechPlaying.value = false
if (audioElement.value === audio) {
audioElement.value = null
}
}
return false
}
} catch (error: any) {
if (speechRequestId === requestId) {
const message = typeof error?.message === 'string' ? error.message : 'The radio voice is unavailable right now.'
speechError.value = message
}
return false
} finally {
if (speechRequestId === requestId) {
speechLoading.value = false
}
}
}
async function handleRadioCheck() {
if (speechLoading.value) return
const success = await speak('Learner, this is Avery. If you can hear me loud and clear, give me a thumbs up and we will taxi to the first stop.', 'radio-check')
if (success) {
hasCompletedRadioCheck.value = true
}
}
async function maybeSpeakForStage() {
if (!tourStarted.value || voiceMode.value !== 'radio' || !hasCompletedRadioCheck.value) return
const stage = activeStage.value
if (!stage?.voiceLine) return
await nextTick()
await speak(stage.voiceLine, stage.id)
}
function replayStageVoice() {
maybeSpeakForStage()
}
async function startTour() {
if (startDisabled.value) return
stageIndex.value = 0
unlockedStageIndex.value = 0
if (!tourStarted.value) {
tourStarted.value = true
await nextTick()
if (typeof window !== 'undefined') {
document.getElementById('orientation')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
await maybeSpeakForStage()
}
function goToStage(index: number) {
if (!tourStarted.value) return
if (index < 0 || index >= stages.length) return
if (index > unlockedStageIndex.value) return
if (stageIndex.value === index) {
maybeSpeakForStage()
return
}
stageIndex.value = index
maybeSpeakForStage()
}
function goToNext() {
if (!canGoNext.value) return
stageIndex.value = Math.min(stages.length - 1, stageIndex.value + 1)
unlockedStageIndex.value = Math.max(unlockedStageIndex.value, stageIndex.value)
maybeSpeakForStage()
}
function goToPrevious() {
if (!canGoPrevious.value) return
stageIndex.value = Math.max(0, stageIndex.value - 1)
maybeSpeakForStage()
}
function restartTour() {
stageIndex.value = 0
unlockedStageIndex.value = 0
if (!tourStarted.value) {
tourStarted.value = true
}
maybeSpeakForStage()
}
function setVoiceMode(mode: VoiceMode) {
if (voiceMode.value === mode) return
voiceMode.value = mode
stopAudio()
if (mode === 'radio') {
hasCompletedRadioCheck.value = false
} else {
speechError.value = null
}
}
watch(
() => [tourStarted.value, stageIndex.value, voiceMode.value, hasCompletedRadioCheck.value],
() => {
maybeSpeakForStage()
}
)
onBeforeUnmount(() => {
stopAudio()
})
</script>
<style scoped>
.voice-toggle {
transition: border-color 0.25s ease, background 0.25s ease, color 0.25s ease, box-shadow 0.25s ease;
}
.voice-toggle .v-icon {
color: color-mix(in srgb, var(--accent) 70%, white 30%);
}
.voice-toggle.active {
background: color-mix(in srgb, var(--accent) 14%, transparent);
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
color: var(--text);
box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 26%, transparent);
}
.voice-toggle.active .v-icon {
color: color-mix(in srgb, var(--accent) 85%, white 15%);
}
.voice-replay {
color: var(--t2);
}
.voice-replay:disabled {
opacity: 0.6;
}
.play-again {
letter-spacing: 0.2em;
text-transform: uppercase;
}
.progress-fill {
height: 0.5rem;
border-radius: 9999px;
background: linear-gradient(90deg, rgba(34, 211, 238, 0.85), rgba(14, 165, 233, 0.92));
box-shadow: 0 0 18px rgba(14, 165, 233, 0.35);
transition: width 0.45s ease;
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.fade-slide-enter-from,
.fade-slide-leave-to {
opacity: 0;
transform: translateY(16px);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="scene">
<div class="scene learn-theme">
<!-- APP BAR -->
<header class="hud" role="banner">
<nav class="hud-inner" aria-label="Global">
@@ -4268,42 +4268,6 @@ onMounted(() => {
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--text) 6%, transparent);
color: var(--text);
font-weight: 600;
text-decoration: none
}
.btn:hover {
background: color-mix(in srgb, var(--text) 10%, transparent)
}
.btn.primary {
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 90%, transparent), color-mix(in srgb, var(--accent) 70%, transparent));
color: #061318;
border-color: color-mix(in srgb, var(--accent) 60%, transparent)
}
.btn.soft {
background: color-mix(in srgb, var(--text) 8%, transparent)
}
.btn.ghost {
background: transparent
}
.btn.mini {
padding: 6px 10px;
font-size: 12px
}
/* HUB tiles */
.hub-head {
margin: 6px 0 10px

View File

@@ -62,6 +62,8 @@ export default defineNuxtConfig({
}
},
css: [
'~/assets/css/global.css', '~/assets/css/opensquawk-glass.css'
'~/assets/css/global.css',
'~/assets/css/opensquawk-glass.css',
'~/assets/css/learn-theme.css'
],
})
})