presentation-speech-overlay.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. 'use client';
  2. import { useState } from 'react';
  3. import { AnimatePresence, motion } from 'motion/react';
  4. import { Play, Pause, Repeat, Loader2, Volume2, ChevronDown, ChevronUp } from 'lucide-react';
  5. import { useI18n } from '@/lib/hooks/use-i18n';
  6. import { AvatarDisplay } from '@/components/ui/avatar-display';
  7. import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator';
  8. import type { PlaybackView } from '@/lib/playback';
  9. import type { Participant } from '@/lib/types/roundtable';
  10. import { cn } from '@/lib/utils';
  11. import { DEFAULT_TEACHER_AVATAR, DEFAULT_STUDENT_AVATAR } from '@/components/roundtable/constants';
  12. const PRESENTATION_BUBBLE_WIDTH = 'w-[min(420px,calc(100vw-3rem))]';
  13. interface PresentationSpeechOverlayProps {
  14. readonly playbackView: PlaybackView;
  15. readonly participants: Participant[];
  16. readonly speakingAgentId: string | null;
  17. readonly isTopicPending: boolean;
  18. readonly userAvatar?: string;
  19. /** Which side this overlay instance renders — 'left' or 'right' */
  20. readonly side?: 'left' | 'right';
  21. readonly onBubbleClick?: () => void;
  22. readonly audioIndicatorState?: AudioIndicatorState;
  23. readonly buttonState?: 'play' | 'bars' | 'restart' | 'none';
  24. readonly isPaused?: boolean;
  25. }
  26. export interface PresentationBubbleModel {
  27. key: string;
  28. role: 'teacher' | 'agent' | 'user';
  29. side: 'left' | 'right';
  30. name: string;
  31. avatar: string;
  32. text: string;
  33. isLoading: boolean;
  34. isTopicPending: boolean;
  35. }
  36. export function buildPresentationBubbleModel({
  37. playbackView,
  38. participants,
  39. speakingAgentId,
  40. isTopicPending,
  41. fallbackTeacherName,
  42. fallbackStudentName,
  43. fallbackUserName,
  44. userAvatar,
  45. }: {
  46. playbackView: PlaybackView;
  47. participants: Participant[];
  48. speakingAgentId: string | null;
  49. isTopicPending: boolean;
  50. fallbackTeacherName: string;
  51. fallbackStudentName: string;
  52. fallbackUserName: string;
  53. userAvatar?: string;
  54. }): PresentationBubbleModel | null {
  55. const { phase, bubbleRole, sourceText } = playbackView;
  56. const showDuringPhase =
  57. phase === 'lecturePlaying' ||
  58. phase === 'lecturePaused' ||
  59. phase === 'discussionActive' ||
  60. phase === 'discussionPaused';
  61. const isLoading = phase === 'discussionActive' && bubbleRole !== null && sourceText === '';
  62. if (!showDuringPhase) return null;
  63. if (bubbleRole !== 'teacher' && bubbleRole !== 'agent' && bubbleRole !== 'user') return null;
  64. if (!sourceText && !isLoading) return null;
  65. const teacherParticipant = participants.find((participant) => participant.role === 'teacher');
  66. const speakingStudent = speakingAgentId
  67. ? participants.find(
  68. (participant) =>
  69. participant.id === speakingAgentId &&
  70. participant.role !== 'teacher' &&
  71. participant.role !== 'user',
  72. )
  73. : null;
  74. if (bubbleRole === 'teacher') {
  75. return {
  76. key: 'teacher',
  77. role: 'teacher',
  78. side: 'left',
  79. name: teacherParticipant?.name || fallbackTeacherName,
  80. avatar: teacherParticipant?.avatar || DEFAULT_TEACHER_AVATAR,
  81. text: sourceText,
  82. isLoading,
  83. isTopicPending,
  84. };
  85. }
  86. if (bubbleRole === 'user') {
  87. const userParticipant = participants.find((p) => p.role === 'user');
  88. return {
  89. key: 'user',
  90. role: 'user',
  91. side: 'right',
  92. name: userParticipant?.name || fallbackUserName,
  93. avatar: userAvatar || userParticipant?.avatar || DEFAULT_STUDENT_AVATAR,
  94. text: sourceText,
  95. isLoading,
  96. isTopicPending,
  97. };
  98. }
  99. return {
  100. key: `agent-${speakingAgentId || 'unknown'}`,
  101. role: 'agent',
  102. side: 'right',
  103. name: speakingStudent?.name || fallbackStudentName,
  104. avatar: speakingStudent?.avatar || DEFAULT_STUDENT_AVATAR,
  105. text: sourceText,
  106. isLoading,
  107. isTopicPending,
  108. };
  109. }
  110. /** Collapsed pill — shows avatar + name, click to expand */
  111. function CollapsedBubblePill({
  112. bubble,
  113. onExpand,
  114. onPlayPause,
  115. isPaused,
  116. }: {
  117. readonly bubble: PresentationBubbleModel;
  118. readonly onExpand: () => void;
  119. readonly onPlayPause?: () => void;
  120. readonly isPaused?: boolean;
  121. }) {
  122. return (
  123. <div className="flex items-center gap-2" onClick={onExpand}>
  124. <div
  125. className={cn(
  126. 'flex items-center gap-2 px-3 py-1.5 rounded-full border backdrop-blur-xl shadow-md cursor-pointer transition-all duration-200',
  127. 'hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]',
  128. bubble.role === 'user'
  129. ? 'bg-violet-50/80 dark:bg-violet-950/70 border-violet-200/70 dark:border-violet-800/60'
  130. : bubble.role === 'agent'
  131. ? 'bg-blue-50/80 dark:bg-blue-950/70 border-blue-200/70 dark:border-blue-800/60'
  132. : 'bg-white/80 dark:bg-gray-900/85 border-gray-200/70 dark:border-gray-700/70',
  133. )}
  134. >
  135. <div
  136. className={cn(
  137. 'w-6 h-6 rounded-full overflow-hidden border shrink-0',
  138. bubble.role === 'user'
  139. ? 'border-violet-300 dark:border-violet-600'
  140. : bubble.role === 'agent'
  141. ? 'border-blue-300 dark:border-blue-600'
  142. : 'border-purple-200 dark:border-purple-700',
  143. )}
  144. >
  145. <AvatarDisplay src={bubble.avatar} alt={bubble.name} />
  146. </div>
  147. <span className="text-xs font-medium text-gray-700 dark:text-gray-200 truncate max-w-[120px]">
  148. {bubble.name}
  149. </span>
  150. <ChevronUp className="w-3 h-3 text-gray-400 dark:text-gray-500 shrink-0" />
  151. </div>
  152. {onPlayPause && (
  153. <div
  154. onClick={(e) => {
  155. e.stopPropagation();
  156. onPlayPause();
  157. }}
  158. className={cn(
  159. 'p-2 rounded-full border backdrop-blur-xl shadow-md cursor-pointer transition-all duration-200',
  160. 'hover:shadow-lg hover:scale-[1.02] active:scale-[0.98]',
  161. bubble.role === 'user'
  162. ? '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'
  163. : bubble.role === 'agent'
  164. ? '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'
  165. : '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',
  166. )}
  167. >
  168. {isPaused ? (
  169. <Play className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400 ml-0.5" />
  170. ) : (
  171. <Pause className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
  172. )}
  173. </div>
  174. )}
  175. </div>
  176. );
  177. }
  178. /** Reusable bubble card — renders the speech bubble content (avatar, name, text) */
  179. export function PresentationBubbleCard({
  180. bubble,
  181. onClick,
  182. onCollapse,
  183. audioIndicatorState,
  184. buttonState,
  185. isPaused,
  186. }: {
  187. readonly bubble: PresentationBubbleModel;
  188. readonly onClick?: () => void;
  189. readonly onCollapse?: () => void;
  190. readonly audioIndicatorState?: AudioIndicatorState;
  191. readonly buttonState?: 'play' | 'bars' | 'restart' | 'none';
  192. readonly isPaused?: boolean;
  193. }) {
  194. const { t } = useI18n();
  195. return (
  196. <div
  197. aria-live="polite"
  198. onClick={onClick}
  199. className={cn(
  200. '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',
  201. onClick && 'cursor-pointer',
  202. bubble.role === 'user'
  203. ? 'bg-violet-50/60 dark:bg-violet-950/55 border-violet-200/70 dark:border-violet-800/60'
  204. : bubble.role === 'agent'
  205. ? 'bg-blue-50/60 dark:bg-blue-950/55 border-blue-200/70 dark:border-blue-800/60'
  206. : 'bg-white/62 dark:bg-gray-900/82 border-gray-200/70 dark:border-gray-700/70',
  207. )}
  208. >
  209. <div className="flex items-center gap-3 px-4 pt-3 pb-2">
  210. <div
  211. className={cn(
  212. 'w-10 h-10 rounded-full overflow-hidden border-2 shadow-sm shrink-0',
  213. bubble.role === 'user'
  214. ? 'border-violet-300 dark:border-violet-600'
  215. : bubble.role === 'agent'
  216. ? 'border-blue-300 dark:border-blue-600'
  217. : 'border-purple-200 dark:border-purple-700',
  218. )}
  219. >
  220. <AvatarDisplay src={bubble.avatar} alt={bubble.name} />
  221. </div>
  222. <div className="min-w-0">
  223. <div
  224. className={cn(
  225. 'text-[11px] font-semibold uppercase tracking-[0.16em]',
  226. bubble.role === 'user'
  227. ? 'text-violet-500 dark:text-violet-300'
  228. : bubble.role === 'agent'
  229. ? 'text-blue-500 dark:text-blue-300'
  230. : 'text-purple-500 dark:text-purple-300',
  231. )}
  232. >
  233. {bubble.role === 'user'
  234. ? t('roundtable.you')
  235. : bubble.role === 'agent'
  236. ? t('settings.agentRoles.student')
  237. : t('settings.agentRoles.teacher')}
  238. </div>
  239. <div className="flex items-center gap-1.5">
  240. <div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
  241. {bubble.name}
  242. </div>
  243. {audioIndicatorState === 'generating' && (
  244. <Loader2 className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 animate-spin" />
  245. )}
  246. {audioIndicatorState === 'playing' && (
  247. <Volume2 className="w-3.5 h-3.5 text-gray-500 dark:text-gray-400" />
  248. )}
  249. </div>
  250. </div>
  251. {onCollapse && (
  252. <div
  253. onClick={(e) => {
  254. e.stopPropagation();
  255. onCollapse();
  256. }}
  257. 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"
  258. >
  259. <ChevronDown className="w-4 h-4" />
  260. </div>
  261. )}
  262. </div>
  263. <div className="ml-4 mr-10 mb-4 max-h-[140px] overflow-y-auto scrollbar-hide">
  264. {bubble.isLoading ? (
  265. <div className="flex gap-1 items-center py-1">
  266. {[0, 0.2, 0.4].map((delay) => (
  267. <motion.div
  268. key={delay}
  269. animate={{ opacity: [0.3, 1, 0.3] }}
  270. transition={{ repeat: Infinity, duration: 1, delay }}
  271. className={cn(
  272. 'w-1.5 h-1.5 rounded-full',
  273. bubble.role === 'user'
  274. ? 'bg-violet-400 dark:bg-violet-500'
  275. : bubble.role === 'agent'
  276. ? 'bg-blue-400 dark:bg-blue-500'
  277. : 'bg-purple-400 dark:bg-purple-500',
  278. )}
  279. />
  280. ))}
  281. </div>
  282. ) : (
  283. <p className="text-[15px] leading-relaxed whitespace-pre-wrap break-words text-gray-800 dark:text-gray-100">
  284. {bubble.text}
  285. {bubble.isTopicPending && (
  286. <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
  287. )}
  288. </p>
  289. )}
  290. </div>
  291. {bubble.role !== 'user' &&
  292. !bubble.isLoading &&
  293. buttonState &&
  294. buttonState !== 'none' &&
  295. (() => {
  296. const barsColor = bubble.role === 'agent' ? '#3b82f6' : '#a855f7';
  297. if (buttonState === 'play') {
  298. return (
  299. <div
  300. onClick={(e) => {
  301. e.stopPropagation();
  302. onClick?.();
  303. }}
  304. 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"
  305. >
  306. <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" />
  307. </div>
  308. );
  309. }
  310. if (buttonState === 'restart') {
  311. return (
  312. <div
  313. onClick={(e) => {
  314. e.stopPropagation();
  315. onClick?.();
  316. }}
  317. 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"
  318. >
  319. <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" />
  320. </div>
  321. );
  322. }
  323. // buttonState === 'bars'
  324. return (
  325. <div
  326. onClick={(e) => {
  327. e.stopPropagation();
  328. onClick?.();
  329. }}
  330. 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"
  331. >
  332. {isPaused ? (
  333. <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" />
  334. ) : (
  335. <>
  336. {/* Breathing bars — visible by default, hidden on hover */}
  337. <div className="flex gap-0.5 items-end justify-center h-3.5 w-3.5 group-hover/bubble:hidden">
  338. <div
  339. className="w-1 rounded-full"
  340. style={{
  341. backgroundColor: barsColor,
  342. animation: 'breathing-bar-1 0.6s ease-in-out infinite',
  343. }}
  344. />
  345. <div
  346. className="w-1 rounded-full"
  347. style={{
  348. backgroundColor: barsColor,
  349. animation: 'breathing-bar-2 0.4s ease-in-out infinite',
  350. }}
  351. />
  352. <div
  353. className="w-1 rounded-full"
  354. style={{
  355. backgroundColor: barsColor,
  356. animation: 'breathing-bar-3 0.5s ease-in-out infinite',
  357. }}
  358. />
  359. </div>
  360. {/* Pause icon on hover */}
  361. <Pause className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400 hidden group-hover/bubble:block" />
  362. </>
  363. )}
  364. </div>
  365. );
  366. })()}
  367. </div>
  368. );
  369. }
  370. export function PresentationSpeechOverlay({
  371. playbackView,
  372. participants,
  373. speakingAgentId,
  374. isTopicPending,
  375. userAvatar,
  376. side = 'left',
  377. onBubbleClick,
  378. audioIndicatorState,
  379. buttonState,
  380. isPaused,
  381. }: PresentationSpeechOverlayProps) {
  382. const { t } = useI18n();
  383. const bubble = buildPresentationBubbleModel({
  384. playbackView,
  385. participants,
  386. speakingAgentId,
  387. isTopicPending,
  388. fallbackTeacherName: t('roundtable.teacher'),
  389. fallbackStudentName: t('settings.agentRoles.student'),
  390. fallbackUserName: t('roundtable.you'),
  391. userAvatar,
  392. });
  393. // Persistent collapse: once collapsed, stay collapsed until user explicitly expands.
  394. // Left/right sides are separate component instances so they track independently.
  395. // Right-side agents share a single instance, so all agents share the same collapse state.
  396. const [isCollapsed, setIsCollapsed] = useState(false);
  397. const matchesSide = !!(bubble && bubble.side === side);
  398. const renderContent = (b: PresentationBubbleModel) => (
  399. <AnimatePresence mode="wait" initial={false}>
  400. {isCollapsed ? (
  401. <motion.div
  402. key="collapsed"
  403. initial={{ opacity: 0, scale: 0.9 }}
  404. animate={{ opacity: 1, scale: 1 }}
  405. exit={{ opacity: 0, scale: 0.9 }}
  406. transition={{ duration: 0.18 }}
  407. >
  408. <CollapsedBubblePill
  409. bubble={b}
  410. onExpand={() => setIsCollapsed(false)}
  411. onPlayPause={onBubbleClick}
  412. isPaused={isPaused}
  413. />
  414. </motion.div>
  415. ) : (
  416. <motion.div
  417. key="expanded"
  418. initial={{ opacity: 0, scale: 0.95 }}
  419. animate={{ opacity: 1, scale: 1 }}
  420. exit={{ opacity: 0, scale: 0.95 }}
  421. transition={{ duration: 0.18 }}
  422. className={PRESENTATION_BUBBLE_WIDTH}
  423. >
  424. <PresentationBubbleCard
  425. bubble={b}
  426. onClick={onBubbleClick}
  427. onCollapse={() => setIsCollapsed(true)}
  428. audioIndicatorState={audioIndicatorState}
  429. buttonState={buttonState}
  430. isPaused={isPaused}
  431. />
  432. </motion.div>
  433. )}
  434. </AnimatePresence>
  435. );
  436. /* ── Left-side overlay: absolute covers stage, renders left bubble + cue ── */
  437. if (side === 'left') {
  438. return (
  439. <div className="absolute inset-0 pointer-events-none">
  440. <AnimatePresence mode="wait">
  441. {matchesSide && bubble && (
  442. <motion.div
  443. key={bubble.key}
  444. initial={{ opacity: 0, x: -20, y: 12 }}
  445. animate={{ opacity: 1, x: 0, y: 0 }}
  446. exit={{ opacity: 0, y: 8 }}
  447. transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
  448. className="absolute bottom-6 left-6 z-30 pointer-events-auto"
  449. >
  450. {renderContent(bubble)}
  451. </motion.div>
  452. )}
  453. </AnimatePresence>
  454. </div>
  455. );
  456. }
  457. /* ── Right-side: inline flow, rendered inside the dock's flex column ── */
  458. return (
  459. <AnimatePresence mode="wait">
  460. {matchesSide && bubble && (
  461. <motion.div
  462. key={bubble.key}
  463. initial={{ opacity: 0, x: 20, y: 12 }}
  464. animate={{ opacity: 1, x: 0, y: 0 }}
  465. exit={{ opacity: 0, y: 8 }}
  466. transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
  467. className="pointer-events-auto"
  468. >
  469. {renderContent(bubble)}
  470. </motion.div>
  471. )}
  472. </AnimatePresence>
  473. );
  474. }