subtitles agent update
This commit is contained in:
parent
219d845093
commit
d6937a08a3
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}`;
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
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;
|
||||
|
||||
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;
|
||||
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