|
|
|
|
| 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; |
| |
| 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, |
| }; |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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, |
| }); |
|
|
| |
| |
| |
| 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> |
| ); |
|
|
| |
| 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> |
| ); |
| } |
|
|
| |
| 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> |
| ); |
| } |
|
|