All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 27s
CI / test-python-voice (push) Successful in 25s
CI / test-bqas (push) Successful in 25s
CI / Deploy (push) Successful in 4s
- Migrate chat API from Ollama to LiteLLM (OpenAI-compatible SSE) - Add 15-min presenter storyline with bilingual scripts for all 20 slides - Add FAQ system (30 entries) with keyword matching for instant answers - Add IntroPresenterSlide with avatar placeholder and start button - Add PresenterOverlay (progress bar, subtitle text, play/pause/stop) - Add AvatarPlaceholder with pulse animation during speaking - Add usePresenterMode hook (state machine: idle→presenting→paused→answering→resuming) - Add 'P' keyboard shortcut to toggle presenter mode - Support [GOTO:slide-id] markers in chat responses - Dynamic slide count (was hardcoded 13, now from SLIDE_ORDER) - TTS stub prepared for future Piper integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
'use client'
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Play, Pause, Square, SkipForward } from 'lucide-react'
|
|
import { Language } from '@/lib/types'
|
|
import { PresenterState } from '@/lib/presenter/types'
|
|
import { SLIDE_ORDER } from '@/lib/hooks/useSlideNavigation'
|
|
import { t } from '@/lib/i18n'
|
|
|
|
interface PresenterOverlayProps {
|
|
state: PresenterState
|
|
currentIndex: number
|
|
totalSlides: number
|
|
progress: number
|
|
displayText: string
|
|
lang: Language
|
|
onPause: () => void
|
|
onResume: () => void
|
|
onStop: () => void
|
|
onSkip: () => void
|
|
}
|
|
|
|
export default function PresenterOverlay({
|
|
state,
|
|
currentIndex,
|
|
totalSlides,
|
|
progress,
|
|
displayText,
|
|
lang,
|
|
onPause,
|
|
onResume,
|
|
onStop,
|
|
onSkip,
|
|
}: PresenterOverlayProps) {
|
|
const i = t(lang)
|
|
const slideName = i.slideNames[currentIndex] || SLIDE_ORDER[currentIndex] || ''
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{state !== 'idle' && (
|
|
<motion.div
|
|
initial={{ y: 100, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
exit={{ y: 100, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
className="fixed bottom-0 left-0 right-0 z-40 pointer-events-none"
|
|
>
|
|
<div className="mx-auto max-w-4xl px-4 pb-4 pointer-events-auto">
|
|
<div className="bg-black/80 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl">
|
|
{/* Progress bar */}
|
|
<div className="h-1 bg-white/5">
|
|
<motion.div
|
|
className="h-full bg-gradient-to-r from-indigo-500 to-purple-500"
|
|
style={{ width: `${Math.min(progress, 100)}%` }}
|
|
transition={{ duration: 0.3 }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="px-4 py-3">
|
|
{/* Top row: slide info + controls */}
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-3">
|
|
{/* State indicator */}
|
|
<div className={`w-2 h-2 rounded-full ${
|
|
state === 'presenting' ? 'bg-green-400 animate-pulse' :
|
|
state === 'paused' ? 'bg-yellow-400' :
|
|
state === 'answering' ? 'bg-blue-400 animate-pulse' :
|
|
state === 'resuming' ? 'bg-indigo-400 animate-pulse' :
|
|
'bg-white/30'
|
|
}`} />
|
|
<span className="text-xs text-white/50 font-medium">
|
|
{lang === 'de' ? 'Folie' : 'Slide'} {currentIndex + 1}/{totalSlides} — {slideName}
|
|
</span>
|
|
<span className="text-xs text-white/30">
|
|
{Math.round(progress)}%
|
|
</span>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
onClick={onSkip}
|
|
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
|
hover:bg-white/20 transition-colors"
|
|
title={lang === 'de' ? 'Naechste Folie' : 'Next slide'}
|
|
>
|
|
<SkipForward className="w-3.5 h-3.5 text-white/60" />
|
|
</button>
|
|
|
|
{state === 'presenting' ? (
|
|
<button
|
|
onClick={onPause}
|
|
className="w-7 h-7 rounded-full bg-white/10 flex items-center justify-center
|
|
hover:bg-white/20 transition-colors"
|
|
title={lang === 'de' ? 'Pausieren' : 'Pause'}
|
|
>
|
|
<Pause className="w-3.5 h-3.5 text-white/60" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={onResume}
|
|
className="w-7 h-7 rounded-full bg-indigo-500/30 flex items-center justify-center
|
|
hover:bg-indigo-500/50 transition-colors"
|
|
title={lang === 'de' ? 'Fortsetzen' : 'Resume'}
|
|
>
|
|
<Play className="w-3.5 h-3.5 text-indigo-300" />
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
onClick={onStop}
|
|
className="w-7 h-7 rounded-full bg-red-500/20 flex items-center justify-center
|
|
hover:bg-red-500/30 transition-colors"
|
|
title={lang === 'de' ? 'Stoppen' : 'Stop'}
|
|
>
|
|
<Square className="w-3 h-3 text-red-400" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Subtitle text */}
|
|
<AnimatePresence mode="wait">
|
|
{displayText && (
|
|
<motion.p
|
|
key={displayText.slice(0, 30)}
|
|
initial={{ opacity: 0, y: 5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -5 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="text-sm text-white/70 leading-relaxed"
|
|
>
|
|
{displayText}
|
|
</motion.p>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* State message */}
|
|
{state === 'paused' && (
|
|
<p className="text-xs text-yellow-400/60 mt-1">
|
|
{lang === 'de' ? 'Pausiert — stellen Sie eine Frage oder druecken Sie Play' : 'Paused — ask a question or press play'}
|
|
</p>
|
|
)}
|
|
{state === 'answering' && (
|
|
<p className="text-xs text-blue-400/60 mt-1">
|
|
{lang === 'de' ? 'Beantworte Ihre Frage...' : 'Answering your question...'}
|
|
</p>
|
|
)}
|
|
{state === 'resuming' && (
|
|
<p className="text-xs text-indigo-400/60 mt-1">
|
|
{lang === 'de' ? 'Setze Praesentation fort...' : 'Resuming presentation...'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
)
|
|
}
|