Compare commits
5 Commits
main
...
nikita/sub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6cb904676 | ||
|
|
c4bb9aec12 | ||
|
|
dafdba11cf | ||
|
|
d6937a08a3 | ||
|
|
219d845093 |
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
78
app/api/agent/spawn/route.ts
Normal file
78
app/api/agent/spawn/route.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { formatChatMessageLinks, RoomContext, VideoConference } from '@livekit/components-react';
|
||||
import { RoomContext, VideoConference } from '@livekit/components-react';
|
||||
import { formatChatMessage } from '@/lib/chat-utils';
|
||||
import {
|
||||
ExternalE2EEKeyProvider,
|
||||
LogLevel,
|
||||
@ -86,7 +87,7 @@ export function VideoConferenceClientImpl(props: {
|
||||
<RoomContext.Provider value={room}>
|
||||
<KeyboardShortcuts />
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessageLinks}
|
||||
chatMessageFormatter={formatChatMessage}
|
||||
SettingsComponent={
|
||||
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
|
||||
}
|
||||
|
||||
@ -2,18 +2,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { decodePassphrase } from '@/lib/client-utils';
|
||||
import { formatChatMessage } from '@/lib/chat-utils';
|
||||
import { DebugMode } from '@/lib/Debug';
|
||||
import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts';
|
||||
import { RecordingIndicator } from '@/lib/RecordingIndicator';
|
||||
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||
import { ConnectionDetails } from '@/lib/types';
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
LocalUserChoices,
|
||||
PreJoin,
|
||||
RoomContext,
|
||||
VideoConference,
|
||||
} from '@livekit/components-react';
|
||||
import { SubtitlesOverlay, SubtitleProvider } from '@/lib/SubtitlesOverlay';
|
||||
import { LocalUserChoices, PreJoin, RoomContext, VideoConference } from '@livekit/components-react';
|
||||
import {
|
||||
ExternalE2EEKeyProvider,
|
||||
RoomOptions,
|
||||
@ -32,7 +28,6 @@ import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
|
||||
|
||||
const CONN_DETAILS_ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
|
||||
const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true';
|
||||
|
||||
export function PageClientImpl(props: {
|
||||
roomName: string;
|
||||
@ -66,7 +61,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%' }}>
|
||||
@ -220,13 +215,16 @@ function VideoConferenceComponent(props: {
|
||||
return (
|
||||
<div className="lk-room-container">
|
||||
<RoomContext.Provider value={room}>
|
||||
<KeyboardShortcuts />
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessageLinks}
|
||||
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
|
||||
/>
|
||||
<DebugMode />
|
||||
<RecordingIndicator />
|
||||
<SubtitleProvider>
|
||||
<KeyboardShortcuts />
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessage}
|
||||
SettingsComponent={SettingsMenu}
|
||||
/>
|
||||
<SubtitlesOverlay />
|
||||
<DebugMode />
|
||||
<RecordingIndicator />
|
||||
</SubtitleProvider>
|
||||
</RoomContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
import styles from '../styles/SettingsMenu.module.css';
|
||||
import { CameraSettings } from './CameraSettings';
|
||||
import { MicrophoneSettings } from './MicrophoneSettings';
|
||||
import { SubtitlesSettings } from './SubtitlesSettings';
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@ -27,6 +28,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
const settings = React.useMemo(() => {
|
||||
return {
|
||||
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
|
||||
subtitles: { label: 'Subtitles' },
|
||||
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
|
||||
};
|
||||
}, []);
|
||||
@ -125,6 +127,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'subtitles' && <SubtitlesSettings />}
|
||||
{activeTab === 'recording' && (
|
||||
<>
|
||||
<h3>Record Meeting</h3>
|
||||
|
||||
78
lib/SubtitleContext.tsx
Normal file
78
lib/SubtitleContext.tsx
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
331
lib/SubtitlesOverlay.tsx
Normal file
331
lib/SubtitlesOverlay.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRoomContext } from '@livekit/components-react';
|
||||
import { RoomEvent, DataPacket_Kind, RemoteParticipant } from 'livekit-client';
|
||||
import styles from '@/styles/Subtitles.module.css';
|
||||
|
||||
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;
|
||||
hasAgent: boolean;
|
||||
summaryEmail: string | null;
|
||||
setSummaryEmail: (email: string | null) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
const [summaryEmail, setSummaryEmail] = React.useState<string | null>(null);
|
||||
|
||||
// Load visual settings and email 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,
|
||||
}));
|
||||
}
|
||||
// Load saved email
|
||||
const savedEmail = localStorage.getItem('summary-email');
|
||||
if (savedEmail) {
|
||||
setSummaryEmail(savedEmail);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSummaryEmail = React.useCallback((email: string | null) => {
|
||||
setSummaryEmail(email);
|
||||
try {
|
||||
if (email) {
|
||||
localStorage.setItem('summary-email', email);
|
||||
} else {
|
||||
localStorage.removeItem('summary-email');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to save summary email:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SubtitleContext.Provider
|
||||
value={{
|
||||
settings,
|
||||
updateSettings,
|
||||
hasAgent,
|
||||
summaryEmail,
|
||||
setSummaryEmail: updateSummaryEmail,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SubtitleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubtitleLine {
|
||||
id: string;
|
||||
speaker: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
function calculateDisplayTime(text: string, queueLength: number = 0): number {
|
||||
const charsPerSecond = 15;
|
||||
const calculated = (text.length / charsPerSecond) * 1000;
|
||||
const baseTime = Math.max(2000, Math.min(8000, calculated));
|
||||
|
||||
// Reduce display time when queue is backing up
|
||||
// Queue 0-1: full time, Queue 2: 70%, Queue 3+: 50%
|
||||
if (queueLength >= 3) return Math.max(1000, baseTime * 0.5);
|
||||
if (queueLength >= 2) return Math.max(1500, baseTime * 0.7);
|
||||
return baseTime;
|
||||
}
|
||||
|
||||
export function SubtitlesOverlay() {
|
||||
const room = useRoomContext();
|
||||
const { settings } = useSubtitleSettings();
|
||||
const [lines, setLines] = React.useState<SubtitleLine[]>([]);
|
||||
const lineIdRef = React.useRef(0);
|
||||
const queueRef = React.useRef<SubtitleLine[]>([]);
|
||||
const rafRef = React.useRef<number | null>(null);
|
||||
const hasActiveContent = React.useRef(false);
|
||||
|
||||
// Process subtitles - clean expired lines and show queued ones
|
||||
// All logic in single setLines call to avoid race conditions
|
||||
const processSubtitles = React.useCallback(() => {
|
||||
const now = Date.now();
|
||||
|
||||
setLines((prev) => {
|
||||
// Filter out expired lines
|
||||
let result = prev.filter((l) => l.expireAt > now);
|
||||
const queueLength = queueRef.current.length;
|
||||
|
||||
// When queue is building up, aggressively expire older lines
|
||||
// Keep only the most recent line when queue has 2+ items waiting
|
||||
if (queueLength >= 2 && result.length > 1) {
|
||||
result = result.slice(-1);
|
||||
}
|
||||
|
||||
// Show next line from queue if:
|
||||
// - No lines are currently showing, OR
|
||||
// - Queue is building up (show new content faster)
|
||||
const shouldShowNext = result.length === 0 || queueLength > 0;
|
||||
|
||||
if (shouldShowNext && queueLength > 0) {
|
||||
const nextLine = queueRef.current.shift()!;
|
||||
nextLine.timestamp = now;
|
||||
nextLine.expireAt = now + calculateDisplayTime(nextLine.text, queueRef.current.length);
|
||||
result = [...result.slice(-1), nextLine]; // Keep max 2 lines
|
||||
}
|
||||
|
||||
// Track if we have content to display (for RAF optimization)
|
||||
hasActiveContent.current = result.length > 0 || queueRef.current.length > 0;
|
||||
|
||||
return result;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// RAF loop - only runs when there's active content
|
||||
const tick = React.useCallback(() => {
|
||||
processSubtitles();
|
||||
|
||||
// Continue loop only if there's content to process
|
||||
if (hasActiveContent.current) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, [processSubtitles]);
|
||||
|
||||
// Start RAF loop when needed
|
||||
const startLoop = React.useCallback(() => {
|
||||
if (rafRef.current === null && settings.enabled) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}, [tick, settings.enabled]);
|
||||
|
||||
// Handle visibility change - immediately clean up when tab becomes visible
|
||||
React.useEffect(() => {
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// Process immediately when tab becomes visible
|
||||
processSubtitles();
|
||||
startLoop();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [settings.enabled, processSubtitles, startLoop]);
|
||||
|
||||
// Clean up RAF when disabled
|
||||
React.useEffect(() => {
|
||||
if (!settings.enabled && rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [settings.enabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!room || !settings.enabled) return;
|
||||
|
||||
const handleData = (
|
||||
payload: Uint8Array,
|
||||
_participant?: RemoteParticipant,
|
||||
_kind?: DataPacket_Kind,
|
||||
topic?: string,
|
||||
) => {
|
||||
if (topic !== 'lk.subtitle') return;
|
||||
|
||||
try {
|
||||
const raw = new TextDecoder().decode(payload).trim();
|
||||
if (!raw) return;
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
const now = Date.now();
|
||||
const newLine: SubtitleLine = {
|
||||
id: `sub-${lineIdRef.current++}`,
|
||||
speaker: data.speaker || 'Unknown',
|
||||
text: data.text || raw,
|
||||
timestamp: now,
|
||||
expireAt: now + calculateDisplayTime(data.text || raw),
|
||||
};
|
||||
|
||||
queueRef.current.push(newLine);
|
||||
|
||||
// If queue is backing up, skip older items more aggressively
|
||||
// Keep only last 2 items to prevent falling too far behind
|
||||
if (queueRef.current.length > 2) {
|
||||
queueRef.current = queueRef.current.slice(-2);
|
||||
}
|
||||
|
||||
// Start processing loop
|
||||
startLoop();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse subtitle:', e);
|
||||
}
|
||||
};
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleData);
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleData);
|
||||
queueRef.current = [];
|
||||
};
|
||||
}, [room, settings.enabled, startLoop]);
|
||||
|
||||
if (!settings.enabled || lines.length === 0) return null;
|
||||
|
||||
const positionClass = {
|
||||
top: styles.positionTop,
|
||||
center: styles.positionCenter,
|
||||
bottom: styles.positionBottom,
|
||||
}[settings.position];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.subtitlesContainer} ${positionClass}`}
|
||||
style={
|
||||
{
|
||||
'--subtitle-font-size': `${settings.fontSize}px`,
|
||||
'--subtitle-bg': settings.backgroundColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={styles.subtitlesInner}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
285
lib/SubtitlesSettings.tsx
Normal file
285
lib/SubtitlesSettings.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useRoomContext } from '@livekit/components-react';
|
||||
import { useSubtitleSettings, SubtitleSettings } from './SubtitlesOverlay';
|
||||
|
||||
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() {
|
||||
const room = useRoomContext();
|
||||
const { settings, updateSettings, hasAgent, summaryEmail, setSummaryEmail } =
|
||||
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 payload = {
|
||||
roomName: room.name,
|
||||
email: email || undefined,
|
||||
e2eePassphrase: getPassphrase(),
|
||||
};
|
||||
console.log('[SubtitlesSettings] Spawning agent with:', payload);
|
||||
|
||||
const response = await fetch('/api/agent/spawn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
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()) {
|
||||
setSummaryEmail(null);
|
||||
updateSettings({ ...settings, enabled: true });
|
||||
setShowPopup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (email: string) => {
|
||||
if (await spawnAgent(email)) {
|
||||
setSummaryEmail(email);
|
||||
updateSettings({ ...settings, enabled: true });
|
||||
setShowPopup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof SubtitleSettings>(key: K, value: SubtitleSettings[K]) => {
|
||||
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" 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>
|
||||
|
||||
{settings.enabled && (
|
||||
<>
|
||||
<section className="lk-button-group">
|
||||
<span className="lk-button">Font Size</span>
|
||||
<div className="lk-button-group-menu">
|
||||
<select
|
||||
className="lk-button"
|
||||
value={settings.fontSize}
|
||||
onChange={(e) => updateSetting('fontSize', Number(e.target.value))}
|
||||
style={{ minWidth: '100px' }}
|
||||
>
|
||||
<option value={18}>Small</option>
|
||||
<option value={24}>Medium</option>
|
||||
<option value={32}>Large</option>
|
||||
<option value={40}>Extra Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lk-button-group">
|
||||
<span className="lk-button">Position</span>
|
||||
<div className="lk-button-group-menu">
|
||||
<select
|
||||
className="lk-button"
|
||||
value={settings.position}
|
||||
onChange={(e) =>
|
||||
updateSetting('position', e.target.value as 'top' | 'center' | 'bottom')
|
||||
}
|
||||
style={{ minWidth: '100px' }}
|
||||
>
|
||||
<option value="top">Top</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="bottom">Bottom</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="lk-button-group">
|
||||
<span className="lk-button">Background</span>
|
||||
<div className="lk-button-group-menu">
|
||||
<select
|
||||
className="lk-button"
|
||||
value={settings.backgroundColor}
|
||||
onChange={(e) => updateSetting('backgroundColor', e.target.value)}
|
||||
style={{ minWidth: '100px' }}
|
||||
>
|
||||
<option value="rgba(0, 0, 0, 0.85)">Dark</option>
|
||||
<option value="rgba(0, 0, 0, 0.6)">Medium</option>
|
||||
<option value="rgba(0, 0, 0, 0.4)">Light</option>
|
||||
<option value="transparent">None</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Summary email status */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.75rem',
|
||||
background: 'var(--lk-bg, rgba(0,0,0,0.3))',
|
||||
borderRadius: '0.375rem',
|
||||
fontSize: '0.8125rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<span style={{ opacity: 0.7 }}>📧</span>
|
||||
<span style={{ opacity: 0.7 }}>Summary:</span>
|
||||
{summaryEmail ? (
|
||||
<span style={{ color: 'var(--lk-accent-bg, #1fd5f9)' }}>{summaryEmail}</span>
|
||||
) : (
|
||||
<span style={{ opacity: 0.5, fontStyle: 'italic' }}>No email set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
lib/SubtitlesToggle.tsx
Normal file
205
lib/SubtitlesToggle.tsx
Normal 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
122
lib/SummaryTooltip.tsx
Normal 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;
|
||||
|
||||
18
lib/chat-utils.ts
Normal file
18
lib/chat-utils.ts
Normal file
@ -0,0 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { formatChatMessageLinks } from '@livekit/components-react';
|
||||
|
||||
/**
|
||||
* Chat message formatter with newline support.
|
||||
* Wraps LiveKit's formatChatMessageLinks and adds <br/> for line breaks.
|
||||
*/
|
||||
export function formatChatMessage(message: string): React.ReactNode {
|
||||
return message
|
||||
.split('\n')
|
||||
.flatMap((line, i) =>
|
||||
i === 0
|
||||
? [formatChatMessageLinks(line)]
|
||||
: [React.createElement('br', { key: `br-${i}` }), formatChatMessageLinks(line)],
|
||||
);
|
||||
}
|
||||
61
lib/useAgentPresence.ts
Normal file
61
lib/useAgentPresence.ts
Normal 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;
|
||||
14
package.json
14
package.json
@ -14,20 +14,20 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-logs": "^5.23.3",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-react": "2.9.16",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/krisp-noise-filter": "0.4.1",
|
||||
"@livekit/track-processors": "^0.7.0",
|
||||
"livekit-client": "2.17.2",
|
||||
"livekit-server-sdk": "2.15.0",
|
||||
"next": "15.2.8",
|
||||
"@livekit/krisp-noise-filter": "0.3.4",
|
||||
"@livekit/track-processors": "^0.6.0",
|
||||
"livekit-client": "2.16.0",
|
||||
"livekit-server-sdk": "2.14.2",
|
||||
"next": "15.2.6",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"tinykeys": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.13",
|
||||
"@types/node": "22.19.1",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"eslint": "9.39.1",
|
||||
|
||||
1032
pnpm-lock.yaml
generated
1032
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
279
styles/Subtitles.module.css
Normal file
279
styles/Subtitles.module.css
Normal file
@ -0,0 +1,279 @@
|
||||
/* Subtitles Overlay - Cinematic Style */
|
||||
|
||||
.subtitlesContainer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
padding: 1.5rem 3rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.positionTop {
|
||||
top: 2rem;
|
||||
}
|
||||
|
||||
.positionCenter {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.positionBottom {
|
||||
bottom: 4rem;
|
||||
}
|
||||
|
||||
.subtitlesInner {
|
||||
max-width: 85%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subtitleLine {
|
||||
font-family: var(--subtitle-font-family);
|
||||
font-size: var(--subtitle-font-size);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 0.6rem 1.4rem;
|
||||
border-radius: 6px;
|
||||
background: var(--subtitle-bg);
|
||||
color: var(--subtitle-text);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
transform-origin: center bottom;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.96);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.speaker {
|
||||
font-weight: 600;
|
||||
margin-right: 0.5em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75em;
|
||||
letter-spacing: 0.08em;
|
||||
color: #4ECDC4; /* Teal accent */
|
||||
}
|
||||
|
||||
.speaker::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: currentColor;
|
||||
border-radius: 50%;
|
||||
margin-left: 0.6em;
|
||||
margin-right: 0.3em;
|
||||
vertical-align: middle;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: 400;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Settings Panel Styles */
|
||||
.settingsPanel {
|
||||
background: rgba(18, 18, 20, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
width: 320px;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0, 0, 0, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
.settingsTitle {
|
||||
font-family: 'TWK Everett', sans-serif;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
margin: 0 0 1rem 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.settingsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.875rem;
|
||||
}
|
||||
|
||||
.settingsRow:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settingsLabel {
|
||||
font-family: 'TWK Everett', sans-serif;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.settingsControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle.active {
|
||||
background: #ff6352;
|
||||
}
|
||||
|
||||
.toggleKnob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.toggle.active .toggleKnob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100px;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff6352;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(255, 99, 82, 0.4);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
|
||||
.sliderValue {
|
||||
font-family: 'TWK Everett', sans-serif;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Select Dropdown */
|
||||
.select {
|
||||
font-family: 'TWK Everett', sans-serif;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.select:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.select:focus {
|
||||
border-color: #ff6352;
|
||||
box-shadow: 0 0 0 2px rgba(255, 99, 82, 0.2);
|
||||
}
|
||||
|
||||
/* Color Picker */
|
||||
.colorPicker {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.colorPicker::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.colorPicker::-webkit-color-swatch {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.settingsDivider {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.subtitlesContainer {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.subtitlesInner {
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.subtitleLine {
|
||||
font-size: calc(var(--subtitle-font-size) * 0.85);
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
134
styles/SummaryTooltip.module.css
Normal file
134
styles/SummaryTooltip.module.css
Normal 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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user