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