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 (
{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)}
)}
);
}