| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- 'use client';
- 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 (
- <div className="flex items-center gap-2" onClick={onExpand}>
- <div
- className={cn(
- 'flex items-center gap-2 px-3 py-1.5 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'
- : bubble.role === 'agent'
- ? 'bg-blue-50/80 dark:bg-blue-950/70 border-blue-200/70 dark:border-blue-800/60'
- : 'bg-white/80 dark:bg-gray-900/85 border-gray-200/70 dark:border-gray-700/70',
- )}
- >
- <div
- className={cn(
- 'w-6 h-6 rounded-full overflow-hidden border shrink-0',
- bubble.role === 'user'
- ? 'border-violet-300 dark:border-violet-600'
- : bubble.role === 'agent'
- ? 'border-blue-300 dark:border-blue-600'
- : 'border-purple-200 dark:border-purple-700',
- )}
- >
- <AvatarDisplay src={bubble.avatar} alt={bubble.name} />
- </div>
- <span className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate max-w-[120px]">
- {bubble.name}
- </span>
- <ChevronUp className="w-3 h-3 text-gray-400 dark:text-gray-500 shrink-0" />
- </div>
- {onPlayPause && (
- <div
- onClick={(e) => {
- 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 ? (
- <Play className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 ml-0.5" />
- ) : (
- <Pause className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
- )}
- </div>
- )}
- </div>
- );
- }
- /** 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 (
- <div
- aria-live="polite"
- onClick={onClick}
- className={cn(
- 'relative w-full min-w-0 rounded-3xl border backdrop-blur-xl shadow-[0_18px_50px_-20px_rgba(0,0,0,0.45)] overflow-hidden group/bubble',
- onClick && 'cursor-pointer',
- bubble.role === 'user'
- ? 'bg-violet-50/60 dark:bg-violet-950/55 border-violet-200/70 dark:border-violet-800/60'
- : bubble.role === 'agent'
- ? 'bg-blue-50/60 dark:bg-blue-950/55 border-blue-200/70 dark:border-blue-800/60'
- : 'bg-white/62 dark:bg-gray-900/82 border-gray-200/70 dark:border-gray-700/70',
- )}
- >
- <div className="flex items-center gap-3 px-4 pt-3 pb-2">
- <div
- className={cn(
- 'w-10 h-10 rounded-full overflow-hidden border-2 shadow-sm shrink-0',
- bubble.role === 'user'
- ? 'border-violet-300 dark:border-violet-600'
- : bubble.role === 'agent'
- ? 'border-blue-300 dark:border-blue-600'
- : 'border-purple-200 dark:border-purple-700',
- )}
- >
- <AvatarDisplay src={bubble.avatar} alt={bubble.name} />
- </div>
- <div className="min-w-0">
- <div
- className={cn(
- 'text-[11px] font-semibold uppercase tracking-[0.16em]',
- bubble.role === 'user'
- ? 'text-violet-500 dark:text-violet-300'
- : bubble.role === 'agent'
- ? 'text-blue-500 dark:text-blue-300'
- : 'text-purple-500 dark:text-purple-300',
- )}
- >
- {bubble.role === 'user'
- ? t('roundtable.you')
- : bubble.role === 'agent'
- ? t('settings.agentRoles.student')
- : t('settings.agentRoles.teacher')}
- </div>
- <div className="flex items-center gap-1.5">
- <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
- {bubble.name}
- </div>
- {audioIndicatorState === 'generating' && (
- <Loader2 className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 animate-spin" />
- )}
- {audioIndicatorState === 'playing' && (
- <Volume2 className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
- )}
- </div>
- </div>
- {onCollapse && (
- <div
- onClick={(e) => {
- 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"
- >
- <ChevronDown className="w-4 h-4" />
- </div>
- )}
- </div>
- <div className="ml-4 mr-10 mb-4 max-h-[140px] overflow-y-auto scrollbar-hide">
- {bubble.isLoading ? (
- <div className="flex gap-1 items-center py-1">
- {[0, 0.2, 0.4].map((delay) => (
- <motion.div
- key={delay}
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{ repeat: Infinity, duration: 1, delay }}
- className={cn(
- 'w-1.5 h-1.5 rounded-full',
- bubble.role === 'user'
- ? 'bg-violet-400 dark:bg-violet-500'
- : bubble.role === 'agent'
- ? 'bg-blue-400 dark:bg-blue-500'
- : 'bg-purple-400 dark:bg-purple-500',
- )}
- />
- ))}
- </div>
- ) : (
- <p className="text-[15px] leading-relaxed whitespace-pre-wrap break-words text-gray-800 dark:text-gray-100">
- {bubble.text}
- {bubble.isTopicPending && (
- <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
- )}
- </p>
- )}
- </div>
- {bubble.role !== 'user' &&
- !bubble.isLoading &&
- buttonState &&
- buttonState !== 'none' &&
- (() => {
- const barsColor = bubble.role === 'agent' ? '#3b82f6' : '#a855f7';
- if (buttonState === 'play') {
- return (
- <div
- onClick={(e) => {
- 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"
- >
- <Play className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400 ml-0.5" />
- </div>
- );
- }
- if (buttonState === 'restart') {
- return (
- <div
- onClick={(e) => {
- 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"
- >
- <Repeat className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400" />
- </div>
- );
- }
- // buttonState === 'bars'
- return (
- <div
- onClick={(e) => {
- 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 ? (
- <Play className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400 ml-0.5" />
- ) : (
- <>
- {/* Breathing bars — visible by default, hidden on hover */}
- <div className="flex gap-0.5 items-end justify-center h-3.5 w-3.5 group-hover/bubble:hidden">
- <div
- className="w-1 rounded-full"
- style={{
- backgroundColor: barsColor,
- animation: 'breathing-bar-1 0.6s ease-in-out infinite',
- }}
- />
- <div
- className="w-1 rounded-full"
- style={{
- backgroundColor: barsColor,
- animation: 'breathing-bar-2 0.4s ease-in-out infinite',
- }}
- />
- <div
- className="w-1 rounded-full"
- style={{
- backgroundColor: barsColor,
- animation: 'breathing-bar-3 0.5s ease-in-out infinite',
- }}
- />
- </div>
- {/* Pause icon on hover */}
- <Pause className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400 hidden group-hover/bubble:block" />
- </>
- )}
- </div>
- );
- })()}
- </div>
- );
- }
- 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) => (
- <AnimatePresence mode="wait" initial={false}>
- {isCollapsed ? (
- <motion.div
- key="collapsed"
- initial={{ opacity: 0, scale: 0.9 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.9 }}
- transition={{ duration: 0.18 }}
- >
- <CollapsedBubblePill
- bubble={b}
- onExpand={() => setIsCollapsed(false)}
- onPlayPause={onBubbleClick}
- isPaused={isPaused}
- />
- </motion.div>
- ) : (
- <motion.div
- key="expanded"
- initial={{ opacity: 0, scale: 0.95 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.95 }}
- transition={{ duration: 0.18 }}
- className={PRESENTATION_BUBBLE_WIDTH}
- >
- <PresentationBubbleCard
- bubble={b}
- onClick={onBubbleClick}
- onCollapse={() => setIsCollapsed(true)}
- audioIndicatorState={audioIndicatorState}
- buttonState={buttonState}
- isPaused={isPaused}
- />
- </motion.div>
- )}
- </AnimatePresence>
- );
- /* ── Left-side overlay: absolute covers stage, renders left bubble + cue ── */
- if (side === 'left') {
- return (
- <div className="absolute inset-0 pointer-events-none">
- <AnimatePresence mode="wait">
- {matchesSide && bubble && (
- <motion.div
- key={bubble.key}
- initial={{ opacity: 0, x: -20, y: 12 }}
- animate={{ opacity: 1, x: 0, y: 0 }}
- exit={{ opacity: 0, y: 8 }}
- transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
- className="absolute bottom-6 left-6 z-30 pointer-events-auto"
- >
- {renderContent(bubble)}
- </motion.div>
- )}
- </AnimatePresence>
- </div>
- );
- }
- /* ── Right-side: inline flow, rendered inside the dock's flex column ── */
- return (
- <AnimatePresence mode="wait">
- {matchesSide && bubble && (
- <motion.div
- key={bubble.key}
- initial={{ opacity: 0, x: 20, y: 12 }}
- animate={{ opacity: 1, x: 0, y: 0 }}
- exit={{ opacity: 0, y: 8 }}
- transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
- className="pointer-events-auto"
- >
- {renderContent(bubble)}
- </motion.div>
- )}
- </AnimatePresence>
- );
- }
|