import { useState } from 'react'; import { AnimatePresence, motion } from 'motion/react'; import { Play, Pause, Repeat, Loader2, Volume2, ChevronDown, ChevronUp } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { AvatarDisplay } from '@/components/ui/avatar-display'; import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator'; import type { PlaybackView } from '@/lib/playback'; import type { Participant } from '@/lib/types/roundtable'; import { cn } from '@/lib/utils'; import { DEFAULT_TEACHER_AVATAR, DEFAULT_STUDENT_AVATAR } from '@/components/roundtable/constants'; const PRESENTATION_BUBBLE_WIDTH = 'w-[min(420px,calc(100vw-3rem))]'; interface PresentationSpeechOverlayProps { readonly playbackView: PlaybackView; readonly participants: Participant[]; readonly speakingAgentId: string | null; readonly isTopicPending: boolean; readonly userAvatar?: string; /** Which side this overlay instance renders — 'left' or 'right' */ readonly side?: 'left' | 'right'; readonly onBubbleClick?: () => void; readonly audioIndicatorState?: AudioIndicatorState; readonly buttonState?: 'play' | 'bars' | 'restart' | 'none'; readonly isPaused?: boolean; } export interface PresentationBubbleModel { key: string; role: 'teacher' | 'agent' | 'user'; side: 'left' | 'right'; name: string; avatar: string; text: string; isLoading: boolean; isTopicPending: boolean; } export function buildPresentationBubbleModel({ playbackView, participants, speakingAgentId, isTopicPending, fallbackTeacherName, fallbackStudentName, fallbackUserName, userAvatar, }: { playbackView: PlaybackView; participants: Participant[]; speakingAgentId: string | null; isTopicPending: boolean; fallbackTeacherName: string; fallbackStudentName: string; fallbackUserName: string; userAvatar?: string; }): PresentationBubbleModel | null { const { phase, bubbleRole, sourceText } = playbackView; const showDuringPhase = phase === 'lecturePlaying' || phase === 'lecturePaused' || phase === 'discussionActive' || phase === 'discussionPaused'; const isLoading = phase === 'discussionActive' && bubbleRole !== null && sourceText === ''; if (!showDuringPhase) return null; if (bubbleRole !== 'teacher' && bubbleRole !== 'agent' && bubbleRole !== 'user') return null; if (!sourceText && !isLoading) return null; const teacherParticipant = participants.find((participant) => participant.role === 'teacher'); const speakingStudent = speakingAgentId ? participants.find( (participant) => participant.id === speakingAgentId && participant.role !== 'teacher' && participant.role !== 'user', ) : null; if (bubbleRole === 'teacher') { return { key: 'teacher', role: 'teacher', side: 'left', name: teacherParticipant?.name || fallbackTeacherName, avatar: teacherParticipant?.avatar || DEFAULT_TEACHER_AVATAR, text: sourceText, isLoading, isTopicPending, }; } if (bubbleRole === 'user') { const userParticipant = participants.find((p) => p.role === 'user'); return { key: 'user', role: 'user', side: 'right', name: userParticipant?.name || fallbackUserName, avatar: userAvatar || userParticipant?.avatar || DEFAULT_STUDENT_AVATAR, text: sourceText, isLoading, isTopicPending, }; } return { key: `agent-${speakingAgentId || 'unknown'}`, role: 'agent', side: 'right', name: speakingStudent?.name || fallbackStudentName, avatar: speakingStudent?.avatar || DEFAULT_STUDENT_AVATAR, text: sourceText, isLoading, isTopicPending, }; } /** Collapsed pill — shows avatar + name, click to expand */ function CollapsedBubblePill({ bubble, onExpand, onPlayPause, isPaused, }: { readonly bubble: PresentationBubbleModel; readonly onExpand: () => void; readonly onPlayPause?: () => void; readonly isPaused?: boolean; }) { return (
{bubble.name}
{onPlayPause && (
{ e.stopPropagation(); onPlayPause(); }} className={cn( 'p-2 rounded-full border backdrop-blur-xl shadow-md cursor-pointer transition-all duration-200', 'hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]', bubble.role === 'user' ? 'bg-violet-50/80 dark:bg-violet-950/70 border-violet-200/70 dark:border-violet-800/60 hover:bg-violet-100 dark:hover:bg-violet-900/70' : bubble.role === 'agent' ? 'bg-blue-50/80 dark:bg-blue-950/70 border-blue-200/70 dark:border-blue-800/60 hover:bg-blue-100 dark:hover:bg-blue-900/70' : 'bg-white/80 dark:bg-gray-900/85 border-gray-200/70 dark:border-gray-700/70 hover:bg-gray-100 dark:hover:bg-gray-800/70', )} > {isPaused ? ( ) : ( )}
)}
); } /** Reusable bubble card — renders the speech bubble content (avatar, name, text) */ export function PresentationBubbleCard({ bubble, onClick, onCollapse, audioIndicatorState, buttonState, isPaused, }: { readonly bubble: PresentationBubbleModel; readonly onClick?: () => void; readonly onCollapse?: () => void; readonly audioIndicatorState?: AudioIndicatorState; readonly buttonState?: 'play' | 'bars' | 'restart' | 'none'; readonly isPaused?: boolean; }) { const { t } = useI18n(); return (
{bubble.role === 'user' ? t('roundtable.you') : bubble.role === 'agent' ? t('settings.agentRoles.student') : t('settings.agentRoles.teacher')}
{bubble.name}
{audioIndicatorState === 'generating' && ( )} {audioIndicatorState === 'playing' && ( )}
{onCollapse && (
{ e.stopPropagation(); onCollapse(); }} className="absolute top-2 right-2 p-1.5 rounded-full text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100/80 dark:hover:bg-gray-800/80 transition-colors duration-200 cursor-pointer z-10" >
)}
{bubble.isLoading ? (
{[0, 0.2, 0.4].map((delay) => ( ))}
) : (

{bubble.text} {bubble.isTopicPending && ( )}

)}
{bubble.role !== 'user' && !bubble.isLoading && buttonState && buttonState !== 'none' && (() => { const barsColor = bubble.role === 'agent' ? '#3b82f6' : '#a855f7'; if (buttonState === 'play') { return (
{ e.stopPropagation(); onClick?.(); }} className="absolute right-2.5 bottom-2.5 z-20 p-1.5 rounded-full bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300 cursor-pointer" >
); } if (buttonState === 'restart') { return (
{ e.stopPropagation(); onClick?.(); }} className="absolute right-2.5 bottom-2.5 z-20 p-1.5 rounded-full bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300 cursor-pointer" >
); } // buttonState === 'bars' return (
{ e.stopPropagation(); onClick?.(); }} className="absolute right-2.5 bottom-2.5 z-20 p-1.5 rounded-full bg-white/40 dark:bg-gray-800/40 backdrop-blur-sm group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300 cursor-pointer" > {isPaused ? ( ) : ( <> {/* Breathing bars — visible by default, hidden on hover */}
{/* Pause icon on hover */} )}
); })()}
); } export function PresentationSpeechOverlay({ playbackView, participants, speakingAgentId, isTopicPending, userAvatar, side = 'left', onBubbleClick, audioIndicatorState, buttonState, isPaused, }: PresentationSpeechOverlayProps) { const { t } = useI18n(); const bubble = buildPresentationBubbleModel({ playbackView, participants, speakingAgentId, isTopicPending, fallbackTeacherName: t('roundtable.teacher'), fallbackStudentName: t('settings.agentRoles.student'), fallbackUserName: t('roundtable.you'), userAvatar, }); // Persistent collapse: once collapsed, stay collapsed until user explicitly expands. // Left/right sides are separate component instances so they track independently. // Right-side agents share a single instance, so all agents share the same collapse state. const [isCollapsed, setIsCollapsed] = useState(false); const matchesSide = !!(bubble && bubble.side === side); const renderContent = (b: PresentationBubbleModel) => ( {isCollapsed ? ( setIsCollapsed(false)} onPlayPause={onBubbleClick} isPaused={isPaused} /> ) : ( setIsCollapsed(true)} audioIndicatorState={audioIndicatorState} buttonState={buttonState} isPaused={isPaused} /> )} ); /* ── Left-side overlay: absolute covers stage, renders left bubble + cue ── */ if (side === 'left') { return (
{matchesSide && bubble && ( {renderContent(bubble)} )}
); } /* ── Right-side: inline flow, rendered inside the dock's flex column ── */ return ( {matchesSide && bubble && ( {renderContent(bubble)} )} ); }