subtitles agent update

This commit is contained in:
Nikita 2025-12-04 14:03:08 -08:00
parent 219d845093
commit d6937a08a3
10 changed files with 987 additions and 172 deletions

View File

@ -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}`;
}

View File

@ -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 (
<main data-lk-theme="default" style={{ height: '100%' }}>
@ -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 (
<div className="lk-room-container">
<RoomContext.Provider value={room}>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SettingsMenu}
/>
<SubtitlesOverlay settings={subtitleSettings} />
<DebugMode />
<RecordingIndicator />
<SubtitleProvider>
<KeyboardShortcuts />
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SettingsMenu}
/>
<SubtitlesOverlay />
<DebugMode />
<RecordingIndicator />
</SubtitleProvider>
</RoomContext.Provider>
</div>
);

View File

@ -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' && (
<SubtitlesSettings
settings={subtitleSettings}
onChange={updateSubtitleSettings}
/>
)}
{activeTab === 'subtitles' && <SubtitlesSettings />}
{activeTab === 'recording' && (
<>
<h3>Record Meeting</h3>

78
lib/SubtitleContext.tsx Normal file
View File

@ -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<SubtitleContextType | null>(null);
export function SubtitleProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = React.useState<SubtitleSettings>(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 (
<SubtitleContext.Provider value={{ settings, updateSettings }}>
{children}
</SubtitleContext.Provider>
);
}
export function useSubtitleSettings() {
const context = React.useContext(SubtitleContext);
if (!context) {
throw new Error('useSubtitleSettings must be used within SubtitleProvider');
}
return context;
}

View File

@ -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<SubtitleContextType | null>(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<SubtitleSettings>(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 (
<SubtitleContext.Provider value={{ settings, updateSettings, hasAgent }}>
{children}
</SubtitleContext.Provider>
);
}
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<SubtitleLine[]>([]);
const lineIdRef = React.useRef(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const queueRef = React.useRef<SubtitleLine[]>([]);
const currentTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
const currentLineIdRef = React.useRef<string | null>(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 (
<div
ref={containerRef}
className={`${styles.subtitlesContainer} ${positionClass}`}
style={
{
'--subtitle-font-size': `${settings.fontSize}px`,
'--subtitle-font-family': '"TWK Everett", sans-serif',
'--subtitle-bg': settings.backgroundColor,
'--subtitle-text': '#f0f0f0',
} as React.CSSProperties
}
>
<div className={styles.subtitlesInner}>
{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 (
<div
key={line.id}
className={styles.subtitleLine}
style={{
opacity: Math.max(0, Math.min(1, opacity)),
animationDelay: `${index * 0.05}s`,
}}
>
<span className={styles.speaker}>{line.speaker}</span>
<span className={styles.text}>{line.text}</span>
</div>
);
})}
{lines.map((line) => (
<div key={line.id} className={styles.subtitleLine}>
<span className={styles.speaker}>{line.speaker}</span>
<span className={styles.text}>{line.text}</span>
</div>
))}
</div>
</div>
);
}
export default SubtitlesOverlay;

View File

@ -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 (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
zIndex: 100,
background: 'var(--lk-bg2, #1a1a1a)',
border: '1px solid var(--lk-border-color, rgba(255,255,255,0.15))',
borderRadius: '0.5rem',
padding: '1rem',
minWidth: '280px',
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
}}
>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.875rem', color: 'rgba(255,255,255,0.9)' }}>
Want to receive a summary of this call?
</p>
<input
type="email"
placeholder="your@email.com (optional)"
value={email}
onChange={(e) => 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 && (
<p style={{ color: '#ff4444', fontSize: '0.75rem', margin: '0 0 0.5rem' }}>{error}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="lk-button"
onClick={onSkip}
disabled={isLoading}
style={{ flex: 1, fontSize: '0.8125rem' }}
>
{isLoading ? 'Loading...' : 'Skip'}
</button>
<button
className="lk-button"
onClick={handleSubscribe}
disabled={isLoading || !email.trim()}
style={{
flex: 1,
fontSize: '0.8125rem',
background: 'var(--lk-accent-bg, #ff6352)',
border: 'none',
}}
>
{isLoading ? 'Loading...' : 'Subscribe'}
</button>
</div>
</div>
);
}
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<string | null>(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 = <K extends keyof SubtitleSettings>(key: K, value: SubtitleSettings[K]) => {
onChange({ ...settings, [key]: value });
updateSettings({ ...settings, [key]: value });
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<h3 style={{ marginBottom: '0.25rem' }}>Subtitles</h3>
<section className="lk-button-group">
<span className="lk-button">Show Subtitles</span>
<div className="lk-button-group-menu">
<button
className="lk-button"
aria-pressed={settings.enabled}
onClick={() => updateSetting('enabled', !settings.enabled)}
>
<div className="lk-button-group-menu" style={{ position: 'relative' }}>
<button className="lk-button" aria-pressed={settings.enabled} onClick={handleToggle}>
{settings.enabled ? 'On' : 'Off'}
</button>
{showPopup && (
<EmailPopup
onSkip={handleSkip}
onSubscribe={handleSubscribe}
onClose={() => !isSpawning && setShowPopup(false)}
isLoading={isSpawning}
error={spawnError}
/>
)}
</div>
</section>
@ -87,70 +256,3 @@ export function SubtitlesSettings({ settings, onChange }: SubtitlesSettingsProps
</div>
);
}
// Hook for managing subtitle settings with localStorage persistence
export function useSubtitleSettings() {
const [settings, setSettings] = React.useState<SubtitleSettings>(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;

205
lib/SubtitlesToggle.tsx Normal file
View File

@ -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<string | null>(null);
const [controlBar, setControlBar] = React.useState<Element | null>(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 = (
<div style={{ position: 'relative', display: 'inline-flex' }}>
<button
className="lk-button"
onClick={handleClick}
aria-pressed={settings.enabled}
title={settings.enabled ? 'Disable subtitles' : 'Enable subtitles'}
>
<SubtitlesIcon enabled={settings.enabled} />
</button>
{showTooltip && (
<SummaryTooltip
onSkip={handleSkip}
onSubscribe={handleSubscribe}
onClose={handleCloseTooltip}
isLoading={isSpawning}
error={spawnError}
/>
)}
</div>
);
// Use portal to inject into control bar
if (!controlBar) {
return null;
}
return createPortal(buttonContent, controlBar);
}
function SubtitlesIcon({ enabled }: { enabled: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ opacity: enabled ? 1 : 0.6 }}
>
<rect
x="1"
y="3"
width="14"
height="10"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
fill="none"
/>
<path d="M4 8h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9 8h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M4 10.5h5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export default SubtitlesToggle;

122
lib/SummaryTooltip.tsx Normal file
View File

@ -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<HTMLDivElement>(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 (
<div className={styles.tooltipContainer} ref={tooltipRef}>
<div className={styles.tooltip}>
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Starting transcription...</span>
</div>
</div>
</div>
);
}
return (
<div className={styles.tooltipContainer} ref={tooltipRef}>
<div className={styles.tooltip}>
<p className={styles.title}>Want to receive a summary of this call?</p>
<input
type="email"
className={styles.emailInput}
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<div className={styles.buttons}>
<button className={styles.skipButton} onClick={onSkip} disabled={isLoading}>
Skip
</button>
<button
className={styles.subscribeButton}
onClick={handleSubscribe}
disabled={isLoading || !email.trim()}
>
Subscribe
</button>
</div>
{error && <div className={styles.error}>{error}</div>}
</div>
</div>
);
}
export default SummaryTooltip;

61
lib/useAgentPresence.ts Normal file
View File

@ -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<RemoteParticipant | null>(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;

View File

@ -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;
}