meet/app/dual-rooms/session/PageClientImpl.tsx
2026-01-13 16:40:33 +01:00

372 lines
12 KiB
TypeScript

'use client';
import React from 'react';
import { decodePassphrase } from '@/lib/client-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,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
VideoCodec,
VideoPresets,
Room,
DeviceUnsupportedError,
RoomConnectOptions,
RoomEvent,
TrackPublishDefaults,
VideoCaptureOptions,
} from 'livekit-client';
import { useRouter } from 'next/navigation';
import { useSetupE2EE } from '@/lib/useSetupE2EE';
import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser';
import { SecondaryRoomView } from './SecondaryRoomView';
import { PrimaryRoomView } from './PrimaryRoomView';
import styles from '@/styles/DualRoom.module.css';
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: {
primaryRoomName: string;
secondaryRoomName: string;
region?: string;
hq: boolean;
codec: VideoCodec;
}) {
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
const [connectionDetails, setConnectionDetails] = React.useState<
{ primary: ConnectionDetails; secondary: ConnectionDetails } | undefined
>(undefined);
const handlePreJoinSubmit = React.useCallback(
async (values: LocalUserChoices) => {
setPreJoinChoices(values);
const url = new URL(CONN_DETAILS_ENDPOINT, window.location.origin);
// Fetch connection details for primary room
const primaryUrl = new URL(url);
primaryUrl.searchParams.append('roomName', props.primaryRoomName);
primaryUrl.searchParams.append('participantName', values.username);
if (props.region) {
primaryUrl.searchParams.append('region', props.region);
}
// Fetch connection details for secondary room
const secondaryUrl = new URL(url);
secondaryUrl.searchParams.append('roomName', props.secondaryRoomName);
secondaryUrl.searchParams.append('participantName', values.username);
if (props.region) {
secondaryUrl.searchParams.append('region', props.region);
}
const [primaryResp, secondaryResp] = await Promise.all([
fetch(primaryUrl.toString()),
fetch(secondaryUrl.toString()),
]);
const [primaryData, secondaryData] = await Promise.all([
primaryResp.json(),
secondaryResp.json(),
]);
setConnectionDetails({
primary: primaryData,
secondary: secondaryData,
});
},
[props.primaryRoomName, props.secondaryRoomName, props.region],
);
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
{connectionDetails === undefined || preJoinChoices === undefined ? (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<PreJoin
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
onError={handlePreJoinError}
/>
</div>
) : (
<DualRoomComponent
connectionDetails={connectionDetails}
userChoices={preJoinChoices}
options={{ codec: props.codec, hq: props.hq }}
/>
)}
</main>
);
}
function DualRoomComponent(props: {
userChoices: LocalUserChoices;
connectionDetails: {
primary: ConnectionDetails;
secondary: ConnectionDetails;
};
options: {
hq: boolean;
codec: VideoCodec;
};
}) {
const [isSecondaryEnlarged, setIsSecondaryEnlarged] = React.useState(false);
// E2EE setup (shared passphrase for both rooms)
const { worker, e2eePassphrase } = useSetupE2EE();
const e2eeEnabled = !!(e2eePassphrase && worker);
// Create separate key providers for each room
const primaryKeyProvider = React.useMemo(() => new ExternalE2EEKeyProvider(), []);
const secondaryKeyProvider = React.useMemo(() => new ExternalE2EEKeyProvider(), []);
const [e2eeSetupComplete, setE2eeSetupComplete] = React.useState(false);
// Primary room options (full quality)
const primaryRoomOptions = React.useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
const videoCaptureDefaults: VideoCaptureOptions = {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: props.options.hq ? VideoPresets.h2160 : VideoPresets.h720,
};
const publishDefaults: TrackPublishDefaults = {
dtx: false,
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
videoCodec,
};
return {
videoCaptureDefaults: videoCaptureDefaults,
publishDefaults: publishDefaults,
audioCaptureDefaults: {
deviceId: props.userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: true,
dynacast: true,
e2ee:
primaryKeyProvider && worker && e2eeEnabled
? { keyProvider: primaryKeyProvider, worker }
: undefined,
singlePeerConnection: true,
};
}, [props.userChoices, props.options.hq, props.options.codec, e2eeEnabled, worker]);
// Secondary room options (optimized for lower bandwidth)
const secondaryRoomOptions = React.useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
const publishDefaults: TrackPublishDefaults = {
dtx: false,
videoSimulcastLayers: [VideoPresets.h360, VideoPresets.h180],
red: !e2eeEnabled,
videoCodec,
};
return {
videoCaptureDefaults: {
deviceId: props.userChoices.videoDeviceId ?? undefined,
resolution: VideoPresets.h360,
},
publishDefaults: publishDefaults,
audioCaptureDefaults: {
deviceId: props.userChoices.audioDeviceId ?? undefined,
},
adaptiveStream: true,
dynacast: true,
e2ee:
secondaryKeyProvider && worker && e2eeEnabled
? { keyProvider: secondaryKeyProvider, worker }
: undefined,
singlePeerConnection: true,
};
}, [props.options.codec, e2eeEnabled, worker, props.userChoices]);
const primaryRoom = React.useMemo(() => new Room(primaryRoomOptions), []);
const secondaryRoom = React.useMemo(() => new Room(secondaryRoomOptions), []);
// Setup E2EE for both rooms
React.useEffect(() => {
if (e2eeEnabled) {
const decodedPassphrase = decodePassphrase(e2eePassphrase);
Promise.all([
primaryKeyProvider.setKey(decodedPassphrase),
secondaryKeyProvider.setKey(decodedPassphrase),
])
.then(() =>
Promise.all([
primaryRoom.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
);
console.error(e);
} else {
throw e;
}
}),
secondaryRoom.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
);
console.error(e);
} else {
throw e;
}
}),
]),
)
.then(() => setE2eeSetupComplete(true));
} else {
setE2eeSetupComplete(true);
}
}, [e2eeEnabled, primaryRoom, secondaryRoom, e2eePassphrase]);
const connectOptions = React.useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
};
}, []);
const router = useRouter();
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
const handleError = React.useCallback((error: Error) => {
console.error(error);
alert(`Encountered an unexpected error, check the console logs for details: ${error.message}`);
}, []);
const handleEncryptionError = React.useCallback((error: Error) => {
console.error(error);
alert(
`Encountered an unexpected encryption error, check the console logs for details: ${error.message}`,
);
}, []);
// Connect both rooms
React.useEffect(() => {
primaryRoom.on(RoomEvent.Disconnected, handleOnLeave);
primaryRoom.on(RoomEvent.EncryptionError, handleEncryptionError);
primaryRoom.on(RoomEvent.MediaDevicesError, handleError);
secondaryRoom.on(RoomEvent.EncryptionError, handleEncryptionError);
secondaryRoom.on(RoomEvent.MediaDevicesError, handleError);
if (e2eeSetupComplete) {
// Connect primary room
primaryRoom
.connect(
props.connectionDetails.primary.serverUrl,
props.connectionDetails.primary.participantToken,
connectOptions,
)
.catch((error) => {
handleError(error);
});
if (props.userChoices.videoEnabled) {
primaryRoom.localParticipant.setCameraEnabled(true).catch((error) => {
handleError(error);
});
}
if (props.userChoices.audioEnabled) {
primaryRoom.localParticipant.setMicrophoneEnabled(true).catch((error) => {
handleError(error);
});
}
// Connect secondary room (view-only by default - no camera/mic published)
secondaryRoom
.connect(
props.connectionDetails.secondary.serverUrl,
props.connectionDetails.secondary.participantToken,
connectOptions,
)
.catch((error) => {
console.error('Secondary room connection error:', error);
});
// Don't enable camera/mic for secondary room by default
// User can enable them explicitly via the secondary room controls
}
return () => {
primaryRoom.off(RoomEvent.Disconnected, handleOnLeave);
primaryRoom.off(RoomEvent.EncryptionError, handleEncryptionError);
primaryRoom.off(RoomEvent.MediaDevicesError, handleError);
secondaryRoom.off(RoomEvent.EncryptionError, handleEncryptionError);
secondaryRoom.off(RoomEvent.MediaDevicesError, handleError);
primaryRoom.disconnect();
secondaryRoom.disconnect();
};
}, [
e2eeSetupComplete,
primaryRoom,
secondaryRoom,
props.connectionDetails,
props.userChoices,
connectOptions,
]);
useLowCPUOptimizer(primaryRoom);
useLowCPUOptimizer(secondaryRoom);
const toggleSecondaryEnlarged = React.useCallback(() => {
setIsSecondaryEnlarged((prev) => !prev);
}, []);
return (
<div className={styles.dualRoomContainer} data-secondary-enlarged={isSecondaryEnlarged}>
<div className={styles.primaryRoom}>
<RoomContext.Provider value={primaryRoom}>
<div className="lk-room-container">
<KeyboardShortcuts />
<PrimaryRoomView
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
<DebugMode />
<RecordingIndicator />
</div>
</RoomContext.Provider>
</div>
<div className={styles.secondaryRoom}>
<RoomContext.Provider value={secondaryRoom}>
<SecondaryRoomView
onToggleEnlarge={toggleSecondaryEnlarged}
isEnlarged={isSecondaryEnlarged}
room={secondaryRoom}
/>
</RoomContext.Provider>
</div>
</div>
);
}