diff --git a/app/api/agent/spawn/route.ts b/app/api/agent/spawn/route.ts new file mode 100644 index 0000000..b69a200 --- /dev/null +++ b/app/api/agent/spawn/route.ts @@ -0,0 +1,78 @@ +import { AgentDispatchClient } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +const API_KEY = process.env.LIVEKIT_API_KEY; +const API_SECRET = process.env.LIVEKIT_API_SECRET; +const LIVEKIT_URL = process.env.LIVEKIT_URL; + +// Agent name must match the one registered in the worker +const AGENT_NAME = 'transcription-agent'; + +interface SpawnRequest { + roomName: string; + email?: string; + e2eePassphrase?: string; +} + +export async function POST(request: NextRequest) { + try { + // Validate environment + if (!API_KEY || !API_SECRET || !LIVEKIT_URL) { + return new NextResponse('LiveKit credentials not configured', { status: 500 }); + } + + // Parse request body + const body: SpawnRequest = await request.json(); + const { roomName, email, e2eePassphrase } = body; + + if (!roomName) { + return new NextResponse('Missing required parameter: roomName', { status: 400 }); + } + + // Create dispatch client + const dispatchClient = new AgentDispatchClient(LIVEKIT_URL, API_KEY, API_SECRET); + + // Build metadata for the agent + const metadata = JSON.stringify({ + roomName, + email: email || null, + passphrase: e2eePassphrase || null, + serverUrl: LIVEKIT_URL, + // Include the base URL so agent can fetch tokens + baseUrl: getBaseUrl(request), + }); + + // Create dispatch request + const dispatch = await dispatchClient.createDispatch(roomName, AGENT_NAME, { + metadata, + }); + + console.log(`Dispatched agent ${AGENT_NAME} to room ${roomName}`, { + dispatchId: dispatch.agentName, + hasEmail: !!email, + hasE2EE: !!e2eePassphrase, + }); + + return NextResponse.json({ + success: true, + agentName: dispatch.agentName, + room: dispatch.room, + }); + } catch (error) { + console.error('Failed to spawn agent:', error); + + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + return new NextResponse('Internal server error', { status: 500 }); + } +} + +// Get the base URL from the request +function getBaseUrl(request: NextRequest): string { + const host = request.headers.get('host') || 'localhost:3000'; + const protocol = request.headers.get('x-forwarded-proto') || 'http'; + return `${protocol}://${host}`; +} + + diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx index 1acb378..205f96b 100644 --- a/app/rooms/[roomName]/PageClientImpl.tsx +++ b/app/rooms/[roomName]/PageClientImpl.tsx @@ -7,8 +7,7 @@ import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts'; import { RecordingIndicator } from '@/lib/RecordingIndicator'; import { SettingsMenu } from '@/lib/SettingsMenu'; import { ConnectionDetails } from '@/lib/types'; -import { SubtitlesOverlay } from '@/lib/SubtitlesOverlay'; -import { useSubtitleSettings } from '@/lib/SubtitlesSettings'; +import { SubtitlesOverlay, SubtitleProvider } from '@/lib/SubtitlesOverlay'; import { formatChatMessageLinks, LocalUserChoices, @@ -68,7 +67,7 @@ export function PageClientImpl(props: { const connectionDetailsData = await connectionDetailsResp.json(); setConnectionDetails(connectionDetailsData); }, []); - const handlePreJoinError = React.useCallback((e: any) => console.error(e), []); + const handlePreJoinError = React.useCallback((e: Error) => console.error(e), []); return (
@@ -102,7 +101,6 @@ function VideoConferenceComponent(props: { const keyProvider = new ExternalE2EEKeyProvider(); const { worker, e2eePassphrase } = useSetupE2EE(); const e2eeEnabled = !!(e2eePassphrase && worker); - const { settings: subtitleSettings } = useSubtitleSettings(); const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false); @@ -223,14 +221,16 @@ function VideoConferenceComponent(props: { return (
- - - - - + + + + + + +
); diff --git a/lib/SettingsMenu.tsx b/lib/SettingsMenu.tsx index 81ed607..c88c57c 100644 --- a/lib/SettingsMenu.tsx +++ b/lib/SettingsMenu.tsx @@ -11,7 +11,7 @@ import { import styles from '../styles/SettingsMenu.module.css'; import { CameraSettings } from './CameraSettings'; import { MicrophoneSettings } from './MicrophoneSettings'; -import { SubtitlesSettings, useSubtitleSettings } from './SubtitlesSettings'; +import { SubtitlesSettings } from './SubtitlesSettings'; /** * @alpha */ @@ -24,7 +24,6 @@ export function SettingsMenu(props: SettingsMenuProps) { const layoutContext = useMaybeLayoutContext(); const room = useRoomContext(); const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT; - const { settings: subtitleSettings, updateSettings: updateSubtitleSettings } = useSubtitleSettings(); const settings = React.useMemo(() => { return { @@ -128,12 +127,7 @@ export function SettingsMenu(props: SettingsMenuProps) { )} )} - {activeTab === 'subtitles' && ( - - )} + {activeTab === 'subtitles' && } {activeTab === 'recording' && ( <>

Record Meeting

diff --git a/lib/SubtitleContext.tsx b/lib/SubtitleContext.tsx new file mode 100644 index 0000000..0ccb90b --- /dev/null +++ b/lib/SubtitleContext.tsx @@ -0,0 +1,78 @@ +'use client'; + +import * as React from 'react'; + +export interface SubtitleSettings { + enabled: boolean; + fontSize: number; + position: 'top' | 'center' | 'bottom'; + backgroundColor: string; +} + +export const defaultSubtitleSettings: SubtitleSettings = { + enabled: false, + fontSize: 24, + position: 'bottom', + backgroundColor: 'rgba(0, 0, 0, 0.85)', +}; + +interface SubtitleContextType { + settings: SubtitleSettings; + updateSettings: (settings: SubtitleSettings) => void; +} + +const SubtitleContext = React.createContext(null); + +export function SubtitleProvider({ children }: { children: React.ReactNode }) { + const [settings, setSettings] = React.useState(defaultSubtitleSettings); + + // Load visual settings from localStorage on mount (enabled always starts false) + React.useEffect(() => { + try { + const saved = localStorage.getItem('subtitle-settings'); + if (saved) { + const parsed = JSON.parse(saved); + setSettings({ + ...defaultSubtitleSettings, + fontSize: parsed.fontSize ?? defaultSubtitleSettings.fontSize, + position: parsed.position ?? defaultSubtitleSettings.position, + backgroundColor: parsed.backgroundColor ?? defaultSubtitleSettings.backgroundColor, + enabled: false, + }); + } + } catch (e) { + console.error('Failed to load subtitle settings:', e); + } + }, []); + + const updateSettings = React.useCallback((newSettings: SubtitleSettings) => { + setSettings(newSettings); + // Save visual settings to localStorage (not enabled) + try { + localStorage.setItem('subtitle-settings', JSON.stringify({ + fontSize: newSettings.fontSize, + position: newSettings.position, + backgroundColor: newSettings.backgroundColor, + })); + } catch (e) { + console.error('Failed to save subtitle settings:', e); + } + }, []); + + return ( + + {children} + + ); +} + +export function useSubtitleSettings() { + const context = React.useContext(SubtitleContext); + if (!context) { + throw new Error('useSubtitleSettings must be used within SubtitleProvider'); + } + return context; +} + + + diff --git a/lib/SubtitlesOverlay.tsx b/lib/SubtitlesOverlay.tsx index f5b7b14..8980f66 100644 --- a/lib/SubtitlesOverlay.tsx +++ b/lib/SubtitlesOverlay.tsx @@ -7,79 +7,150 @@ import styles from '@/styles/Subtitles.module.css'; export interface SubtitleSettings { enabled: boolean; - fontSize: number; // 18-40 + fontSize: number; position: 'top' | 'center' | 'bottom'; backgroundColor: string; } export const defaultSubtitleSettings: SubtitleSettings = { - enabled: true, + enabled: false, fontSize: 24, position: 'bottom', backgroundColor: 'rgba(0, 0, 0, 0.85)', }; +interface SubtitleContextType { + settings: SubtitleSettings; + updateSettings: (settings: SubtitleSettings) => void; + hasAgent: boolean; +} + +const SubtitleContext = React.createContext(null); + +export function useSubtitleSettings() { + const context = React.useContext(SubtitleContext); + if (!context) { + throw new Error('useSubtitleSettings must be used within SubtitleProvider'); + } + return context; +} + +const AGENT_NAME = 'LiveKit Transcription'; + +export function SubtitleProvider({ children }: { children: React.ReactNode }) { + const room = useRoomContext(); + const [settings, setSettings] = React.useState(defaultSubtitleSettings); + const [hasAgent, setHasAgent] = React.useState(false); + + // Load visual settings from localStorage + React.useEffect(() => { + try { + const saved = localStorage.getItem('subtitle-settings'); + if (saved) { + const parsed = JSON.parse(saved); + setSettings((prev) => ({ + ...prev, + fontSize: parsed.fontSize ?? prev.fontSize, + position: parsed.position ?? prev.position, + backgroundColor: parsed.backgroundColor ?? prev.backgroundColor, + })); + } + } catch (e) { + console.error('Failed to load subtitle settings:', e); + } + }, []); + + // Check for agent presence + const checkAgent = React.useCallback(() => { + if (!room) { + setHasAgent(false); + return; + } + for (const p of room.remoteParticipants.values()) { + if (p.name === AGENT_NAME) { + setHasAgent(true); + return; + } + } + setHasAgent(false); + }, [room]); + + React.useEffect(() => { + if (!room) return; + checkAgent(); + room.on(RoomEvent.ParticipantConnected, checkAgent); + room.on(RoomEvent.ParticipantDisconnected, checkAgent); + return () => { + room.off(RoomEvent.ParticipantConnected, checkAgent); + room.off(RoomEvent.ParticipantDisconnected, checkAgent); + }; + }, [room, checkAgent]); + + const updateSettings = React.useCallback((newSettings: SubtitleSettings) => { + setSettings(newSettings); + try { + localStorage.setItem( + 'subtitle-settings', + JSON.stringify({ + fontSize: newSettings.fontSize, + position: newSettings.position, + backgroundColor: newSettings.backgroundColor, + }), + ); + } catch (e) { + console.error('Failed to save settings:', e); + } + }, []); + + return ( + + {children} + + ); +} + interface SubtitleLine { id: string; speaker: string; text: string; timestamp: number; - displayTime: number; // calculated display time in ms + displayTime: number; } -// Calculate display time based on text length -// Average reading speed: ~200 words per minute = ~3.3 words per second -// Average word length: ~5 characters -// So roughly 16-17 characters per second for comfortable reading -// Min 2s, max 8s function calculateDisplayTime(text: string): number { const charsPerSecond = 15; - const minTime = 2000; // 2 seconds - const maxTime = 8000; // 8 seconds const calculated = (text.length / charsPerSecond) * 1000; - return Math.max(minTime, Math.min(maxTime, calculated)); + return Math.max(2000, Math.min(8000, calculated)); } -interface SubtitlesOverlayProps { - settings: SubtitleSettings; -} - -export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { +export function SubtitlesOverlay() { const room = useRoomContext(); + const { settings } = useSubtitleSettings(); const [lines, setLines] = React.useState([]); const lineIdRef = React.useRef(0); - const containerRef = React.useRef(null); const queueRef = React.useRef([]); const currentTimeoutRef = React.useRef(null); const currentLineIdRef = React.useRef(null); - // Show next subtitle from queue const showNext = React.useCallback(() => { - // Clear any pending timeout if (currentTimeoutRef.current) { clearTimeout(currentTimeoutRef.current); currentTimeoutRef.current = null; } - // Remove current line immediately if exists if (currentLineIdRef.current) { setLines((prev) => prev.filter((l) => l.id !== currentLineIdRef.current)); currentLineIdRef.current = null; } - // Nothing in queue - if (queueRef.current.length === 0) { - return; - } + if (queueRef.current.length === 0) return; - // Get next line const nextLine = queueRef.current.shift()!; nextLine.timestamp = Date.now(); currentLineIdRef.current = nextLine.id; - setLines((prev) => [...prev.slice(-2), nextLine]); // Keep max 3 lines + setLines((prev) => [...prev.slice(-2), nextLine]); - // Schedule next subtitle currentTimeoutRef.current = setTimeout(() => { setLines((prev) => prev.filter((l) => l.id !== nextLine.id)); currentLineIdRef.current = null; @@ -87,14 +158,13 @@ export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { }, nextLine.displayTime); }, []); - // Listen for data messages on lk.subtitle topic React.useEffect(() => { if (!room || !settings.enabled) return; - const handleDataReceived = ( + const handleData = ( payload: Uint8Array, - participant?: RemoteParticipant, - kind?: DataPacket_Kind, + _participant?: RemoteParticipant, + _kind?: DataPacket_Kind, topic?: string, ) => { if (topic !== 'lk.subtitle') return; @@ -103,23 +173,17 @@ export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { const raw = new TextDecoder().decode(payload).trim(); if (!raw) return; - // Parse JSON: {speaker, text} const data = JSON.parse(raw); - const speaker = data.speaker || 'Unknown'; - const text = data.text || raw; - const displayTime = calculateDisplayTime(text); - const newLine: SubtitleLine = { id: `sub-${lineIdRef.current++}`, - speaker, - text, + speaker: data.speaker || 'Unknown', + text: data.text || raw, timestamp: Date.now(), - displayTime, + displayTime: calculateDisplayTime(data.text || raw), }; queueRef.current.push(newLine); - // If queue is growing (more than 2), immediately switch to next if (queueRef.current.length > 2) { showNext(); } else if (!currentLineIdRef.current) { @@ -130,18 +194,14 @@ export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { } }; - room.on(RoomEvent.DataReceived, handleDataReceived); + room.on(RoomEvent.DataReceived, handleData); return () => { - room.off(RoomEvent.DataReceived, handleDataReceived); - if (currentTimeoutRef.current) { - clearTimeout(currentTimeoutRef.current); - } + room.off(RoomEvent.DataReceived, handleData); + if (currentTimeoutRef.current) clearTimeout(currentTimeoutRef.current); }; }, [room, settings.enabled, showNext]); - if (!settings.enabled || lines.length === 0) { - return null; - } + if (!settings.enabled || lines.length === 0) return null; const positionClass = { top: styles.positionTop, @@ -151,41 +211,22 @@ export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { return (
- {lines.map((line, index) => { - // Calculate fade based on display time - const age = Date.now() - line.timestamp; - const fadeStart = line.displayTime * 0.7; // Start fading at 70% - const opacity = age > fadeStart ? 1 - (age - fadeStart) / (line.displayTime * 0.3) : 1; - - return ( -
- {line.speaker} - {line.text} -
- ); - })} + {lines.map((line) => ( +
+ {line.speaker} + {line.text} +
+ ))}
); } - -export default SubtitlesOverlay; diff --git a/lib/SubtitlesSettings.tsx b/lib/SubtitlesSettings.tsx index 4084b2b..6eb822e 100644 --- a/lib/SubtitlesSettings.tsx +++ b/lib/SubtitlesSettings.tsx @@ -1,31 +1,200 @@ 'use client'; import * as React from 'react'; -import { SubtitleSettings, defaultSubtitleSettings } from './SubtitlesOverlay'; +import { useRoomContext } from '@livekit/components-react'; +import { useSubtitleSettings, SubtitleSettings } from './SubtitlesOverlay'; -interface SubtitlesSettingsProps { - settings: SubtitleSettings; - onChange: (settings: SubtitleSettings) => void; +function EmailPopup({ + onSkip, + onSubscribe, + onClose, + isLoading, + error, +}: { + onSkip: () => void; + onSubscribe: (email: string) => void; + onClose: () => void; + isLoading: boolean; + error: string | null; +}) { + const [email, setEmail] = React.useState(() => { + if (typeof window !== 'undefined') { + return localStorage.getItem('summary-email') || ''; + } + return ''; + }); + + const handleSubscribe = () => { + if (email.trim()) { + localStorage.setItem('summary-email', email.trim()); + onSubscribe(email.trim()); + } + }; + + return ( +
+

+ Want to receive a summary of this call? +

+ setEmail(e.target.value)} + disabled={isLoading} + style={{ + width: '100%', + padding: '0.5rem 0.75rem', + background: 'var(--lk-bg, #111)', + border: '1px solid var(--lk-border-color, rgba(255,255,255,0.15))', + borderRadius: '0.375rem', + color: 'white', + fontSize: '0.875rem', + marginBottom: '0.75rem', + outline: 'none', + boxSizing: 'border-box', + }} + /> + {error && ( +

{error}

+ )} +
+ + +
+
+ ); } -export function SubtitlesSettings({ settings, onChange }: SubtitlesSettingsProps) { +export function SubtitlesSettings() { + const room = useRoomContext(); + const { settings, updateSettings, hasAgent } = useSubtitleSettings(); + const [showPopup, setShowPopup] = React.useState(false); + const [isSpawning, setIsSpawning] = React.useState(false); + const [spawnError, setSpawnError] = React.useState(null); + + const getPassphrase = () => { + if (typeof window !== 'undefined') { + const hash = window.location.hash; + return hash?.length > 1 ? hash.substring(1) : undefined; + } + return undefined; + }; + + const spawnAgent = async (email?: string) => { + if (!room?.name) { + setSpawnError('Room not available'); + return false; + } + + setIsSpawning(true); + setSpawnError(null); + + try { + const response = await fetch('/api/agent/spawn', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + roomName: room.name, + email: email || undefined, + e2eePassphrase: getPassphrase(), + }), + }); + + if (!response.ok) { + throw new Error((await response.text()) || 'Failed to spawn agent'); + } + return true; + } catch (error) { + setSpawnError(error instanceof Error ? error.message : 'Failed'); + return false; + } finally { + setIsSpawning(false); + } + }; + + const handleToggle = () => { + if (settings.enabled) { + updateSettings({ ...settings, enabled: false }); + } else if (hasAgent) { + updateSettings({ ...settings, enabled: true }); + } else { + setShowPopup(true); + setSpawnError(null); + } + }; + + const handleSkip = async () => { + if (await spawnAgent()) { + updateSettings({ ...settings, enabled: true }); + setShowPopup(false); + } + }; + + const handleSubscribe = async (email: string) => { + if (await spawnAgent(email)) { + updateSettings({ ...settings, enabled: true }); + setShowPopup(false); + } + }; + const updateSetting = (key: K, value: SubtitleSettings[K]) => { - onChange({ ...settings, [key]: value }); + updateSettings({ ...settings, [key]: value }); }; return (

Subtitles

+
Show Subtitles -
- + {showPopup && ( + !isSpawning && setShowPopup(false)} + isLoading={isSpawning} + error={spawnError} + /> + )}
@@ -87,70 +256,3 @@ export function SubtitlesSettings({ settings, onChange }: SubtitlesSettingsProps
); } - -// Hook for managing subtitle settings with localStorage persistence -export function useSubtitleSettings() { - const [settings, setSettings] = React.useState(defaultSubtitleSettings); - const [isLoaded, setIsLoaded] = React.useState(false); - - // Load from localStorage on mount - React.useEffect(() => { - try { - const saved = localStorage.getItem('subtitle-settings'); - if (saved) { - setSettings({ ...defaultSubtitleSettings, ...JSON.parse(saved) }); - } - } catch (e) { - console.error('Failed to load subtitle settings:', e); - } - setIsLoaded(true); - }, []); - - // Listen for storage changes from other components - React.useEffect(() => { - const handleStorageChange = (e: StorageEvent) => { - if (e.key === 'subtitle-settings' && e.newValue) { - try { - setSettings({ ...defaultSubtitleSettings, ...JSON.parse(e.newValue) }); - } catch (err) { - console.error('Failed to parse subtitle settings:', err); - } - } - }; - - // Also listen for custom event for same-tab updates - const handleCustomEvent = () => { - try { - const saved = localStorage.getItem('subtitle-settings'); - if (saved) { - setSettings({ ...defaultSubtitleSettings, ...JSON.parse(saved) }); - } - } catch (e) { - console.error('Failed to load subtitle settings:', e); - } - }; - - window.addEventListener('storage', handleStorageChange); - window.addEventListener('subtitle-settings-updated', handleCustomEvent); - return () => { - window.removeEventListener('storage', handleStorageChange); - window.removeEventListener('subtitle-settings-updated', handleCustomEvent); - }; - }, []); - - // Save to localStorage on change and notify other components - const updateSettings = React.useCallback((newSettings: SubtitleSettings) => { - setSettings(newSettings); - try { - localStorage.setItem('subtitle-settings', JSON.stringify(newSettings)); - // Dispatch custom event to notify same-tab listeners - window.dispatchEvent(new Event('subtitle-settings-updated')); - } catch (e) { - console.error('Failed to save subtitle settings:', e); - } - }, []); - - return { settings, updateSettings, isLoaded }; -} - -export default SubtitlesSettings; diff --git a/lib/SubtitlesToggle.tsx b/lib/SubtitlesToggle.tsx new file mode 100644 index 0000000..d1b3287 --- /dev/null +++ b/lib/SubtitlesToggle.tsx @@ -0,0 +1,205 @@ +'use client'; + +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { useRoomContext } from '@livekit/components-react'; +import { SummaryTooltip } from './SummaryTooltip'; +import { useAgentPresence } from './useAgentPresence'; +import { SubtitleSettings } from './SubtitlesOverlay'; + +interface SubtitlesToggleProps { + settings: SubtitleSettings; + onToggle: (enabled: boolean) => void; +} + +/** + * Subtitles toggle button that injects itself into the LiveKit ControlBar. + * Uses React Portal to teleport into .lk-control-bar element. + */ +export function SubtitlesToggle({ settings, onToggle }: SubtitlesToggleProps) { + const room = useRoomContext(); + const { hasAgent } = useAgentPresence(); + const [showTooltip, setShowTooltip] = React.useState(false); + const [isSpawning, setIsSpawning] = React.useState(false); + const [spawnError, setSpawnError] = React.useState(null); + const [controlBar, setControlBar] = React.useState(null); + + // Find the control bar element and inject our button + React.useEffect(() => { + const findControlBar = () => { + const bar = document.querySelector('.lk-control-bar'); + if (bar) { + setControlBar(bar); + } + }; + + // Try immediately + findControlBar(); + + // Also observe DOM changes in case control bar renders later + const observer = new MutationObserver(() => { + if (!controlBar) { + findControlBar(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + return () => observer.disconnect(); + }, [controlBar]); + + // Get E2EE passphrase from URL hash if present + const getPassphrase = React.useCallback(() => { + if (typeof window !== 'undefined') { + const hash = window.location.hash; + if (hash && hash.length > 1) { + return hash.substring(1); + } + } + return undefined; + }, []); + + // Spawn agent via API + const spawnAgent = React.useCallback( + async (email?: string) => { + if (!room?.name) { + setSpawnError('Room not available'); + return false; + } + + setIsSpawning(true); + setSpawnError(null); + + try { + const passphrase = getPassphrase(); + const response = await fetch('/api/agent/spawn', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + roomName: room.name, + email: email || undefined, + e2eePassphrase: passphrase, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || 'Failed to spawn agent'); + } + + return true; + } catch (error) { + console.error('Failed to spawn agent:', error); + setSpawnError(error instanceof Error ? error.message : 'Failed to start transcription'); + return false; + } finally { + setIsSpawning(false); + } + }, + [room?.name, getPassphrase], + ); + + // Handle button click + const handleClick = React.useCallback(() => { + if (settings.enabled) { + // Turn off subtitles + onToggle(false); + } else { + // Turning on - check if agent present + if (hasAgent) { + onToggle(true); + } else { + // Show tooltip to spawn agent + setShowTooltip(true); + setSpawnError(null); + } + } + }, [settings.enabled, hasAgent, onToggle]); + + // Handle skip (no email) + const handleSkip = React.useCallback(async () => { + const success = await spawnAgent(); + if (success) { + onToggle(true); + setShowTooltip(false); + } + }, [spawnAgent, onToggle]); + + // Handle subscribe (with email) + const handleSubscribe = React.useCallback( + async (email: string) => { + const success = await spawnAgent(email); + if (success) { + onToggle(true); + setShowTooltip(false); + } + }, + [spawnAgent, onToggle], + ); + + // Close tooltip + const handleCloseTooltip = React.useCallback(() => { + if (!isSpawning) { + setShowTooltip(false); + setSpawnError(null); + } + }, [isSpawning]); + + const buttonContent = ( +
+ + {showTooltip && ( + + )} +
+ ); + + // Use portal to inject into control bar + if (!controlBar) { + return null; + } + + return createPortal(buttonContent, controlBar); +} + +function SubtitlesIcon({ enabled }: { enabled: boolean }) { + return ( + + + + + + + ); +} + +export default SubtitlesToggle; diff --git a/lib/SummaryTooltip.tsx b/lib/SummaryTooltip.tsx new file mode 100644 index 0000000..6f4e8d8 --- /dev/null +++ b/lib/SummaryTooltip.tsx @@ -0,0 +1,122 @@ +'use client'; + +import * as React from 'react'; +import styles from '@/styles/SummaryTooltip.module.css'; + +const STORAGE_KEY = 'summary-email'; + +interface SummaryTooltipProps { + onSkip: () => void; + onSubscribe: (email: string) => void; + onClose: () => void; + isLoading?: boolean; + error?: string | null; +} + +export function SummaryTooltip({ + onSkip, + onSubscribe, + onClose, + isLoading = false, + error = null, +}: SummaryTooltipProps) { + const [email, setEmail] = React.useState(''); + const tooltipRef = React.useRef(null); + + // Load email from localStorage on mount + React.useEffect(() => { + try { + const savedEmail = localStorage.getItem(STORAGE_KEY); + if (savedEmail) { + setEmail(savedEmail); + } + } catch (e) { + console.error('Failed to load email from localStorage:', e); + } + }, []); + + // Close tooltip when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (tooltipRef.current && !tooltipRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + // Add listener with a small delay to prevent immediate close + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handleSubscribe = () => { + if (email.trim()) { + // Save email to localStorage + try { + localStorage.setItem(STORAGE_KEY, email.trim()); + } catch (e) { + console.error('Failed to save email to localStorage:', e); + } + onSubscribe(email.trim()); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && email.trim()) { + handleSubscribe(); + } else if (e.key === 'Escape') { + onClose(); + } + }; + + if (isLoading) { + return ( +
+
+
+
+ Starting transcription... +
+
+
+ ); + } + + return ( +
+
+

Want to receive a summary of this call?

+ setEmail(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + /> +
+ + +
+ {error &&
{error}
} +
+
+ ); +} + +export default SummaryTooltip; + diff --git a/lib/useAgentPresence.ts b/lib/useAgentPresence.ts new file mode 100644 index 0000000..10113ca --- /dev/null +++ b/lib/useAgentPresence.ts @@ -0,0 +1,61 @@ +'use client'; + +import * as React from 'react'; +import { useRoomContext } from '@livekit/components-react'; +import { RoomEvent, RemoteParticipant } from 'livekit-client'; + +// Agent name must match config.py BOT_NAME +const TRANSCRIPTION_AGENT_NAME = 'LiveKit Transcription'; + +/** + * Hook to detect if a transcription agent is present in the room. + * Checks for participant with name "LiveKit Transcription". + */ +export function useAgentPresence() { + const room = useRoomContext(); + const [hasAgent, setHasAgent] = React.useState(false); + const [agentParticipant, setAgentParticipant] = React.useState(null); + + const checkForAgent = React.useCallback(() => { + if (!room) { + setHasAgent(false); + setAgentParticipant(null); + return; + } + + // Check all remote participants for our transcription agent by name + for (const participant of room.remoteParticipants.values()) { + if (participant.name === TRANSCRIPTION_AGENT_NAME) { + setHasAgent(true); + setAgentParticipant(participant); + return; + } + } + + setHasAgent(false); + setAgentParticipant(null); + }, [room]); + + React.useEffect(() => { + if (!room) return; + + // Initial check + checkForAgent(); + + // Listen for participant changes + const handleParticipantConnected = () => checkForAgent(); + const handleParticipantDisconnected = () => checkForAgent(); + + room.on(RoomEvent.ParticipantConnected, handleParticipantConnected); + room.on(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected); + + return () => { + room.off(RoomEvent.ParticipantConnected, handleParticipantConnected); + room.off(RoomEvent.ParticipantDisconnected, handleParticipantDisconnected); + }; + }, [room, checkForAgent]); + + return { hasAgent, agentParticipant, checkForAgent }; +} + +export default useAgentPresence; diff --git a/styles/SummaryTooltip.module.css b/styles/SummaryTooltip.module.css new file mode 100644 index 0000000..3d1071c --- /dev/null +++ b/styles/SummaryTooltip.module.css @@ -0,0 +1,134 @@ +.tooltipContainer { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 0.5rem; + z-index: 100; +} + +.tooltip { + background: var(--lk-bg2, #1a1a1a); + border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15)); + border-radius: 0.5rem; + padding: 1rem; + min-width: 280px; + max-width: 320px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} + +.tooltip::after { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + border: 8px solid transparent; + border-bottom-color: var(--lk-bg2, #1a1a1a); +} + +.title { + font-size: 0.875rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + margin: 0 0 0.75rem 0; + line-height: 1.4; +} + +.emailInput { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--lk-bg, #111); + border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15)); + border-radius: 0.375rem; + color: white; + font-size: 0.875rem; + margin-bottom: 0.75rem; + outline: none; + transition: border-color 0.2s; +} + +.emailInput:focus { + border-color: var(--lk-accent-bg, #ff6352); +} + +.emailInput::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.buttons { + display: flex; + gap: 0.5rem; +} + +.skipButton { + flex: 1; + padding: 0.5rem 0.75rem; + background: transparent; + border: 1px solid var(--lk-border-color, rgba(255, 255, 255, 0.15)); + border-radius: 0.375rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.2s; +} + +.skipButton:hover { + background: rgba(255, 255, 255, 0.05); + color: white; +} + +.subscribeButton { + flex: 1; + padding: 0.5rem 0.75rem; + background: var(--lk-accent-bg, #ff6352); + border: none; + border-radius: 0.375rem; + color: white; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.subscribeButton:hover { + opacity: 0.9; +} + +.subscribeButton:disabled, +.skipButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem; + color: rgba(255, 255, 255, 0.7); + font-size: 0.8125rem; +} + +.spinner { + width: 1rem; + height: 1rem; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--lk-accent-bg, #ff6352); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error { + color: #ff4444; + font-size: 0.75rem; + margin-top: 0.5rem; +} +