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>
77 lines
2.9 KiB
TypeScript
77 lines
2.9 KiB
TypeScript
'use client'
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { PresenterState } from '@/lib/presenter/types'
|
|
|
|
interface AvatarPlaceholderProps {
|
|
state: PresenterState
|
|
}
|
|
|
|
export default function AvatarPlaceholder({ state }: AvatarPlaceholderProps) {
|
|
const isSpeaking = state === 'presenting' || state === 'answering'
|
|
const isIdle = state === 'idle'
|
|
|
|
if (isIdle) return null
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
className="fixed bottom-24 right-6 z-40"
|
|
>
|
|
<div className="relative w-16 h-16">
|
|
{/* Pulse rings when speaking */}
|
|
{isSpeaking && (
|
|
<>
|
|
<motion.div
|
|
className="absolute inset-0 rounded-full border border-indigo-400/30"
|
|
animate={{ scale: [1, 1.4, 1], opacity: [0.3, 0, 0.3] }}
|
|
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
|
|
/>
|
|
<motion.div
|
|
className="absolute inset-0 rounded-full border border-purple-400/20"
|
|
animate={{ scale: [1, 1.6, 1], opacity: [0.2, 0, 0.2] }}
|
|
transition={{ duration: 2, repeat: Infinity, ease: 'easeInOut', delay: 0.2 }}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Avatar circle */}
|
|
<motion.div
|
|
className={`w-16 h-16 rounded-full flex items-center justify-center shadow-lg
|
|
${isSpeaking
|
|
? 'bg-gradient-to-br from-indigo-500/40 to-purple-500/40 border-2 border-indigo-400/50'
|
|
: 'bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-400/30'
|
|
}`}
|
|
animate={isSpeaking ? { scale: [1, 1.05, 1] } : {}}
|
|
transition={{ duration: 0.8, repeat: Infinity, ease: 'easeInOut' }}
|
|
>
|
|
{/* Bot icon */}
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
|
|
className={isSpeaking ? 'text-indigo-300' : 'text-indigo-400/60'}
|
|
>
|
|
<rect x="3" y="11" width="18" height="10" rx="2" />
|
|
<circle cx="12" cy="5" r="2" />
|
|
<path d="M12 7v4" />
|
|
<circle cx="8" cy="16" r="1" fill="currentColor" />
|
|
<circle cx="16" cy="16" r="1" fill="currentColor" />
|
|
</svg>
|
|
</motion.div>
|
|
|
|
{/* State dot */}
|
|
<div className={`absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-black/80
|
|
${state === 'presenting' ? 'bg-green-400' :
|
|
state === 'paused' ? 'bg-yellow-400' :
|
|
state === 'answering' ? 'bg-blue-400' :
|
|
'bg-indigo-400'
|
|
}`}
|
|
/>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)
|
|
}
|