From 219d8450933e25edc5f50c42574a02740a92ee53 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 1 Dec 2025 11:43:21 -0800 Subject: [PATCH] Subtitles overlay --- app/rooms/[roomName]/PageClientImpl.tsx | 6 +- lib/SettingsMenu.tsx | 9 + lib/SubtitlesOverlay.tsx | 191 ++++++++++++++++ lib/SubtitlesSettings.tsx | 156 +++++++++++++ styles/Subtitles.module.css | 279 ++++++++++++++++++++++++ 5 files changed, 640 insertions(+), 1 deletion(-) create mode 100644 lib/SubtitlesOverlay.tsx create mode 100644 lib/SubtitlesSettings.tsx create mode 100644 styles/Subtitles.module.css diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx index d31d6bf..1acb378 100644 --- a/app/rooms/[roomName]/PageClientImpl.tsx +++ b/app/rooms/[roomName]/PageClientImpl.tsx @@ -7,6 +7,8 @@ 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 { formatChatMessageLinks, LocalUserChoices, @@ -100,6 +102,7 @@ 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,8 +226,9 @@ function VideoConferenceComponent(props: { + diff --git a/lib/SettingsMenu.tsx b/lib/SettingsMenu.tsx index 9cf7c35..81ed607 100644 --- a/lib/SettingsMenu.tsx +++ b/lib/SettingsMenu.tsx @@ -11,6 +11,7 @@ import { import styles from '../styles/SettingsMenu.module.css'; import { CameraSettings } from './CameraSettings'; import { MicrophoneSettings } from './MicrophoneSettings'; +import { SubtitlesSettings, useSubtitleSettings } from './SubtitlesSettings'; /** * @alpha */ @@ -23,10 +24,12 @@ 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 { media: { camera: true, microphone: true, label: 'Media Devices', speaker: true }, + subtitles: { label: 'Subtitles' }, recording: recordingEndpoint ? { label: 'Recording' } : undefined, }; }, []); @@ -125,6 +128,12 @@ export function SettingsMenu(props: SettingsMenuProps) { )} )} + {activeTab === 'subtitles' && ( + + )} {activeTab === 'recording' && ( <>

Record Meeting

diff --git a/lib/SubtitlesOverlay.tsx b/lib/SubtitlesOverlay.tsx new file mode 100644 index 0000000..f5b7b14 --- /dev/null +++ b/lib/SubtitlesOverlay.tsx @@ -0,0 +1,191 @@ +'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; // 18-40 + position: 'top' | 'center' | 'bottom'; + backgroundColor: string; +} + +export const defaultSubtitleSettings: SubtitleSettings = { + enabled: true, + fontSize: 24, + position: 'bottom', + backgroundColor: 'rgba(0, 0, 0, 0.85)', +}; + +interface SubtitleLine { + id: string; + speaker: string; + text: string; + timestamp: number; + displayTime: number; // calculated display time in ms +} + +// 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)); +} + +interface SubtitlesOverlayProps { + settings: SubtitleSettings; +} + +export function SubtitlesOverlay({ settings }: SubtitlesOverlayProps) { + const room = useRoomContext(); + const [lines, setLines] = React.useState([]); + const lineIdRef = React.useRef(0); + const containerRef = React.useRef(null); + const queueRef = React.useRef([]); + const currentTimeoutRef = React.useRef(null); + const currentLineIdRef = React.useRef(null); + + // Show next subtitle from queue + const showNext = React.useCallback(() => { + // Clear any pending timeout + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + currentTimeoutRef.current = null; + } + + // Remove current line immediately if exists + if (currentLineIdRef.current) { + setLines((prev) => prev.filter((l) => l.id !== currentLineIdRef.current)); + currentLineIdRef.current = null; + } + + // Nothing in queue + if (queueRef.current.length === 0) { + return; + } + + // 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 + + // Schedule next subtitle + currentTimeoutRef.current = setTimeout(() => { + setLines((prev) => prev.filter((l) => l.id !== nextLine.id)); + currentLineIdRef.current = null; + showNext(); + }, nextLine.displayTime); + }, []); + + // Listen for data messages on lk.subtitle topic + React.useEffect(() => { + if (!room || !settings.enabled) return; + + const handleDataReceived = ( + 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; + + // 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, + timestamp: Date.now(), + displayTime, + }; + + 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) { + showNext(); + } + } catch (e) { + console.error('Failed to parse subtitle:', e); + } + }; + + room.on(RoomEvent.DataReceived, handleDataReceived); + return () => { + room.off(RoomEvent.DataReceived, handleDataReceived); + if (currentTimeoutRef.current) { + clearTimeout(currentTimeoutRef.current); + } + }; + }, [room, settings.enabled, showNext]); + + if (!settings.enabled || lines.length === 0) { + return null; + } + + const positionClass = { + top: styles.positionTop, + center: styles.positionCenter, + bottom: styles.positionBottom, + }[settings.position]; + + return ( +
+
+ {lines.map((line, index) => { + // Calculate fade based on display time + const age = Date.now() - line.timestamp; + const fadeStart = line.displayTime * 0.7; // Start fading at 70% + const opacity = age > fadeStart ? 1 - (age - fadeStart) / (line.displayTime * 0.3) : 1; + + return ( +
+ {line.speaker} + {line.text} +
+ ); + })} +
+
+ ); +} + +export default SubtitlesOverlay; diff --git a/lib/SubtitlesSettings.tsx b/lib/SubtitlesSettings.tsx new file mode 100644 index 0000000..4084b2b --- /dev/null +++ b/lib/SubtitlesSettings.tsx @@ -0,0 +1,156 @@ +'use client'; + +import * as React from 'react'; +import { SubtitleSettings, defaultSubtitleSettings } from './SubtitlesOverlay'; + +interface SubtitlesSettingsProps { + settings: SubtitleSettings; + onChange: (settings: SubtitleSettings) => void; +} + +export function SubtitlesSettings({ settings, onChange }: SubtitlesSettingsProps) { + const updateSetting = (key: K, value: SubtitleSettings[K]) => { + onChange({ ...settings, [key]: value }); + }; + + return ( +
+

Subtitles

+
+ Show Subtitles +
+ +
+
+ + {settings.enabled && ( + <> +
+ Font Size +
+ +
+
+ +
+ Position +
+ +
+
+ +
+ Background +
+ +
+
+ + )} +
+ ); +} + +// Hook for managing subtitle settings with localStorage persistence +export function useSubtitleSettings() { + const [settings, setSettings] = React.useState(defaultSubtitleSettings); + const [isLoaded, setIsLoaded] = React.useState(false); + + // Load from localStorage on mount + React.useEffect(() => { + try { + const saved = localStorage.getItem('subtitle-settings'); + if (saved) { + setSettings({ ...defaultSubtitleSettings, ...JSON.parse(saved) }); + } + } catch (e) { + console.error('Failed to load subtitle settings:', e); + } + setIsLoaded(true); + }, []); + + // Listen for storage changes from other components + React.useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === 'subtitle-settings' && e.newValue) { + try { + setSettings({ ...defaultSubtitleSettings, ...JSON.parse(e.newValue) }); + } catch (err) { + console.error('Failed to parse subtitle settings:', err); + } + } + }; + + // Also listen for custom event for same-tab updates + const handleCustomEvent = () => { + try { + const saved = localStorage.getItem('subtitle-settings'); + if (saved) { + setSettings({ ...defaultSubtitleSettings, ...JSON.parse(saved) }); + } + } catch (e) { + console.error('Failed to load subtitle settings:', e); + } + }; + + window.addEventListener('storage', handleStorageChange); + window.addEventListener('subtitle-settings-updated', handleCustomEvent); + return () => { + window.removeEventListener('storage', handleStorageChange); + window.removeEventListener('subtitle-settings-updated', handleCustomEvent); + }; + }, []); + + // Save to localStorage on change and notify other components + const updateSettings = React.useCallback((newSettings: SubtitleSettings) => { + setSettings(newSettings); + try { + localStorage.setItem('subtitle-settings', JSON.stringify(newSettings)); + // Dispatch custom event to notify same-tab listeners + window.dispatchEvent(new Event('subtitle-settings-updated')); + } catch (e) { + console.error('Failed to save subtitle settings:', e); + } + }, []); + + return { settings, updateSettings, isLoaded }; +} + +export default SubtitlesSettings; diff --git a/styles/Subtitles.module.css b/styles/Subtitles.module.css new file mode 100644 index 0000000..2e13a61 --- /dev/null +++ b/styles/Subtitles.module.css @@ -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; + } +} + + +