canvas-toolbar.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. 'use client';
  2. import { useState, useRef, useCallback, useEffect } from 'react';
  3. import {
  4. ChevronLeft,
  5. ChevronRight,
  6. Play,
  7. Pause,
  8. PencilLine,
  9. LayoutList,
  10. MessageSquare,
  11. Volume1,
  12. Volume2,
  13. VolumeX,
  14. Repeat,
  15. Maximize2,
  16. Minimize2,
  17. } from 'lucide-react';
  18. import { cn } from '@/lib/utils';
  19. import { useStageStore } from '@/lib/store';
  20. import { useI18n } from '@/lib/hooks/use-i18n';
  21. import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
  22. export interface CanvasToolbarProps {
  23. readonly currentSceneIndex: number;
  24. readonly scenesCount: number;
  25. readonly engineState: 'idle' | 'playing' | 'paused';
  26. readonly isLiveSession?: boolean;
  27. readonly whiteboardOpen: boolean;
  28. readonly sidebarCollapsed?: boolean;
  29. readonly chatCollapsed?: boolean;
  30. readonly onToggleSidebar?: () => void;
  31. readonly onToggleChat?: () => void;
  32. readonly onPrevSlide: () => void;
  33. readonly onNextSlide: () => void;
  34. readonly onPlayPause: () => void;
  35. readonly onWhiteboardClose: () => void;
  36. readonly showStopDiscussion?: boolean;
  37. readonly onStopDiscussion?: () => void;
  38. readonly isPresenting?: boolean;
  39. readonly onTogglePresentation?: () => void;
  40. readonly className?: string;
  41. // Audio/playback controls
  42. readonly ttsEnabled?: boolean;
  43. readonly ttsMuted?: boolean;
  44. readonly ttsVolume?: number;
  45. readonly onToggleMute?: () => void;
  46. readonly onVolumeChange?: (volume: number) => void;
  47. readonly autoPlayLecture?: boolean;
  48. readonly onToggleAutoPlay?: () => void;
  49. readonly playbackSpeed?: number;
  50. readonly onCycleSpeed?: () => void;
  51. }
  52. /* Compact control button */
  53. const ctrlBtn = cn(
  54. 'relative w-7 h-7 rounded-md flex items-center justify-center',
  55. 'transition-all duration-150 outline-none cursor-pointer',
  56. 'hover:bg-gray-500/[0.08] dark:hover:bg-gray-400/[0.08] active:scale-90',
  57. );
  58. /* Subtle separator */
  59. function CtrlDivider() {
  60. return <div className="w-px h-3 bg-gray-200/80 dark:bg-gray-700/60 mx-0.5 shrink-0" />;
  61. }
  62. /* Volume icon based on level */
  63. function VolumeIcon({
  64. muted,
  65. volume,
  66. disabled,
  67. }: {
  68. muted: boolean;
  69. volume: number;
  70. disabled: boolean;
  71. }) {
  72. const cls = 'w-3.5 h-3.5';
  73. if (disabled || muted || volume === 0) return <VolumeX className={cls} />;
  74. if (volume < 0.5) return <Volume1 className={cls} />;
  75. return <Volume2 className={cls} />;
  76. }
  77. export function CanvasToolbar({
  78. currentSceneIndex,
  79. scenesCount,
  80. engineState,
  81. isLiveSession,
  82. whiteboardOpen,
  83. sidebarCollapsed,
  84. chatCollapsed,
  85. onToggleSidebar,
  86. onToggleChat,
  87. onPrevSlide,
  88. onNextSlide,
  89. onPlayPause,
  90. onWhiteboardClose,
  91. showStopDiscussion,
  92. onStopDiscussion,
  93. isPresenting,
  94. onTogglePresentation,
  95. className,
  96. ttsEnabled,
  97. ttsMuted,
  98. ttsVolume = 1,
  99. onToggleMute,
  100. onVolumeChange,
  101. autoPlayLecture,
  102. onToggleAutoPlay,
  103. playbackSpeed = 1,
  104. onCycleSpeed,
  105. }: CanvasToolbarProps) {
  106. const { t } = useI18n();
  107. const canGoPrev = currentSceneIndex > 0;
  108. const canGoNext = currentSceneIndex < scenesCount - 1;
  109. const showPlayPause = !isLiveSession;
  110. const whiteboardElementCount = useStageStore(
  111. (s) => s.stage?.whiteboard?.[0]?.elements?.length || 0,
  112. );
  113. // Volume slider hover state
  114. const [volumeHover, setVolumeHover] = useState(false);
  115. const volumeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
  116. const volumeContainerRef = useRef<HTMLDivElement>(null);
  117. const handleVolumeEnter = useCallback(() => {
  118. clearTimeout(volumeTimerRef.current);
  119. setVolumeHover(true);
  120. }, []);
  121. const handleVolumeLeave = useCallback(() => {
  122. volumeTimerRef.current = setTimeout(() => setVolumeHover(false), 300);
  123. }, []);
  124. // Cleanup volume hover timer on unmount
  125. useEffect(() => () => clearTimeout(volumeTimerRef.current), []);
  126. // Effective volume for display
  127. const effectiveVolume = ttsMuted ? 0 : ttsVolume;
  128. const presentationLabel = isPresenting ? t('stage.exitFullscreen') : t('stage.fullscreen');
  129. return (
  130. <div className={cn('flex items-center gap-2', className)}>
  131. {/* ── Left: sidebar toggle + page indicator ── */}
  132. <div className="flex items-center gap-1 shrink-0 pl-1">
  133. {onToggleSidebar && (
  134. <button
  135. onClick={onToggleSidebar}
  136. className={cn(
  137. ctrlBtn,
  138. 'w-6 h-6',
  139. sidebarCollapsed
  140. ? 'text-gray-400 dark:text-gray-500'
  141. : 'text-gray-600 dark:text-gray-300',
  142. )}
  143. aria-label="Toggle sidebar"
  144. >
  145. <LayoutList className="w-3.5 h-3.5" />
  146. </button>
  147. )}
  148. <span className="text-[11px] text-gray-400 dark:text-gray-500 tabular-nums select-none font-medium">
  149. {currentSceneIndex + 1}
  150. <span className="opacity-35 mx-px">/</span>
  151. {scenesCount}
  152. </span>
  153. </div>
  154. <CtrlDivider />
  155. {/* ── Center: unified playback controls ── */}
  156. <div className="flex-1 flex items-center justify-center min-w-0">
  157. <div
  158. className={cn(
  159. 'inline-flex items-center gap-0.5 px-1 h-7',
  160. isPresenting
  161. ? '' /* Single visual layer in fullscreen — buttons sit inside outer pill directly */
  162. : 'bg-gray-100/60 dark:bg-gray-800/60 rounded-lg',
  163. )}
  164. >
  165. {/* Volume with vertical popover slider */}
  166. {onToggleMute && (
  167. <div
  168. ref={volumeContainerRef}
  169. className="relative flex items-center"
  170. onMouseEnter={handleVolumeEnter}
  171. onMouseLeave={handleVolumeLeave}
  172. >
  173. <button
  174. onClick={onToggleMute}
  175. disabled={!ttsEnabled}
  176. className={cn(
  177. ctrlBtn,
  178. 'w-6 h-6',
  179. !ttsEnabled
  180. ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed'
  181. : ttsMuted
  182. ? 'text-red-500 dark:text-red-400'
  183. : 'text-gray-500 dark:text-gray-400',
  184. )}
  185. aria-label={ttsMuted ? 'Unmute' : 'Mute'}
  186. >
  187. <VolumeIcon muted={!!ttsMuted} volume={ttsVolume} disabled={!ttsEnabled} />
  188. </button>
  189. {/* Vertical volume slider (pops up above) */}
  190. <div
  191. className={cn(
  192. 'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 flex flex-col items-center',
  193. 'transition-all duration-200 ease-out pointer-events-none opacity-0',
  194. volumeHover && ttsEnabled && 'pointer-events-auto opacity-100',
  195. )}
  196. >
  197. <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg px-2 py-2.5 flex flex-col items-center gap-1.5">
  198. <span className="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums font-medium select-none">
  199. {Math.round(effectiveVolume * 100)}
  200. </span>
  201. <input
  202. type="range"
  203. min={0}
  204. max={1}
  205. step={0.05}
  206. value={effectiveVolume}
  207. onChange={(e) => {
  208. const v = parseFloat(e.target.value);
  209. onVolumeChange?.(v);
  210. if (v > 0 && ttsMuted) onToggleMute?.();
  211. }}
  212. className={cn(
  213. 'appearance-none cursor-pointer',
  214. 'h-16 w-1 rounded-full',
  215. 'bg-gray-200 dark:bg-gray-600',
  216. '[writing-mode:vertical-lr] [direction:rtl]',
  217. '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3',
  218. '[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-violet-500 [&::-webkit-slider-thumb]:dark:bg-violet-400',
  219. '[&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer',
  220. '[&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:h-3',
  221. '[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-violet-500 [&::-moz-range-thumb]:border-0',
  222. )}
  223. />
  224. </div>
  225. {/* Arrow pointing down */}
  226. <div className="w-2 h-2 bg-white dark:bg-gray-800 border-b border-r border-gray-200 dark:border-gray-700 rotate-45 -mt-[5px]" />
  227. </div>
  228. </div>
  229. )}
  230. {/* Speed */}
  231. {onCycleSpeed && (
  232. <TooltipProvider delayDuration={0}>
  233. <Tooltip>
  234. <TooltipTrigger asChild>
  235. <button
  236. onClick={onCycleSpeed}
  237. className={cn(
  238. 'w-8 h-5 rounded flex items-center justify-center',
  239. 'transition-all duration-150 outline-none cursor-pointer',
  240. 'text-[11px] font-semibold tabular-nums leading-none',
  241. 'active:scale-90',
  242. playbackSpeed !== 1
  243. ? 'text-violet-600 dark:text-violet-400 bg-violet-500/10 dark:bg-violet-400/10'
  244. : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200',
  245. )}
  246. aria-label="Playback speed"
  247. >
  248. {playbackSpeed === 1.5 ? '1.5x' : `${playbackSpeed}x`}
  249. </button>
  250. </TooltipTrigger>
  251. <TooltipContent side="top" className="text-xs">
  252. {t('roundtable.speed')}
  253. </TooltipContent>
  254. </Tooltip>
  255. </TooltipProvider>
  256. )}
  257. <CtrlDivider />
  258. {/* Prev scene */}
  259. {scenesCount > 1 && (
  260. <button
  261. onClick={onPrevSlide}
  262. disabled={!canGoPrev}
  263. className={cn(
  264. ctrlBtn,
  265. 'w-6 h-6 text-gray-500 dark:text-gray-400 disabled:opacity-20 disabled:pointer-events-none',
  266. )}
  267. aria-label="Previous scene"
  268. >
  269. <ChevronLeft className="w-3.5 h-3.5" />
  270. </button>
  271. )}
  272. {/* Play / Pause / Stop Discussion */}
  273. {showStopDiscussion && onStopDiscussion ? (
  274. <button
  275. onClick={(e) => {
  276. e.stopPropagation();
  277. onStopDiscussion();
  278. }}
  279. className={cn(
  280. 'flex items-center gap-1.5 h-6 px-2.5 rounded-md',
  281. 'bg-red-500/10 dark:bg-red-400/10 text-red-600 dark:text-red-400',
  282. 'text-[11px] font-semibold whitespace-nowrap',
  283. 'hover:bg-red-500/20 dark:hover:bg-red-400/20 active:scale-95 transition-all cursor-pointer',
  284. )}
  285. title={t('roundtable.stopDiscussion')}
  286. >
  287. <span className="relative flex h-1.5 w-1.5 shrink-0">
  288. <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
  289. <span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-red-500" />
  290. </span>
  291. {t('roundtable.stopDiscussion')}
  292. </button>
  293. ) : showPlayPause ? (
  294. <button
  295. onClick={onPlayPause}
  296. className={cn(
  297. ctrlBtn,
  298. 'w-7 h-6',
  299. engineState === 'playing'
  300. ? 'text-violet-600 dark:text-violet-400'
  301. : 'text-gray-500 dark:text-gray-400',
  302. )}
  303. aria-label={engineState === 'playing' ? 'Pause' : 'Play'}
  304. >
  305. {engineState === 'playing' ? (
  306. <Pause className="w-3.5 h-3.5" />
  307. ) : (
  308. <Play className="w-3.5 h-3.5 ml-px" />
  309. )}
  310. </button>
  311. ) : null}
  312. {/* Next scene */}
  313. {scenesCount > 1 && (
  314. <button
  315. onClick={onNextSlide}
  316. disabled={!canGoNext}
  317. className={cn(
  318. ctrlBtn,
  319. 'w-6 h-6 text-gray-500 dark:text-gray-400 disabled:opacity-20 disabled:pointer-events-none',
  320. )}
  321. aria-label="Next scene"
  322. >
  323. <ChevronRight className="w-3.5 h-3.5" />
  324. </button>
  325. )}
  326. <CtrlDivider />
  327. {/* Auto-play */}
  328. {onToggleAutoPlay && (
  329. <TooltipProvider delayDuration={0}>
  330. <Tooltip>
  331. <TooltipTrigger asChild>
  332. <button
  333. onClick={onToggleAutoPlay}
  334. className={cn(
  335. ctrlBtn,
  336. 'w-8 h-6',
  337. autoPlayLecture
  338. ? 'text-violet-600 dark:text-violet-400'
  339. : 'text-gray-500 dark:text-gray-400',
  340. )}
  341. aria-label="Auto-play"
  342. >
  343. <Repeat className="w-3.5 h-3.5" />
  344. </button>
  345. </TooltipTrigger>
  346. <TooltipContent side="top" className="text-xs">
  347. {autoPlayLecture ? t('roundtable.autoPlayOff') : t('roundtable.autoPlay')}
  348. </TooltipContent>
  349. </Tooltip>
  350. </TooltipProvider>
  351. )}
  352. {/* Whiteboard */}
  353. <button
  354. onClick={(e) => {
  355. e.stopPropagation();
  356. onWhiteboardClose();
  357. }}
  358. className={cn(
  359. ctrlBtn,
  360. 'w-6 h-6',
  361. whiteboardOpen
  362. ? 'text-violet-600 dark:text-violet-400'
  363. : 'text-gray-500 dark:text-gray-400',
  364. )}
  365. title={whiteboardOpen ? t('whiteboard.minimize') : t('whiteboard.open')}
  366. >
  367. <PencilLine className="w-3.5 h-3.5" />
  368. {!whiteboardOpen && whiteboardElementCount > 0 && (
  369. <span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-violet-500 dark:bg-violet-400 rounded-full" />
  370. )}
  371. </button>
  372. </div>
  373. </div>
  374. {/* ── Right: fullscreen + chat toggle ── */}
  375. <div className="flex items-center justify-end gap-px shrink-0 pr-1">
  376. <CtrlDivider />
  377. {onTogglePresentation && (
  378. <button
  379. onClick={onTogglePresentation}
  380. className={cn(
  381. ctrlBtn,
  382. 'w-6 h-6',
  383. isPresenting
  384. ? 'text-violet-600 dark:text-violet-400'
  385. : 'text-gray-500 dark:text-gray-400',
  386. )}
  387. aria-label={presentationLabel}
  388. title={presentationLabel}
  389. >
  390. {isPresenting ? (
  391. <Minimize2 className="w-3.5 h-3.5" />
  392. ) : (
  393. <Maximize2 className="w-3.5 h-3.5" />
  394. )}
  395. </button>
  396. )}
  397. {onToggleChat && (
  398. <button
  399. onClick={onToggleChat}
  400. className={cn(
  401. ctrlBtn,
  402. 'w-6 h-6',
  403. chatCollapsed
  404. ? 'text-gray-400 dark:text-gray-500'
  405. : 'text-gray-600 dark:text-gray-300',
  406. )}
  407. aria-label="Toggle chat"
  408. >
  409. <MessageSquare className="w-3.5 h-3.5" />
  410. </button>
  411. )}
  412. </div>
  413. </div>
  414. );
  415. }