dual room

This commit is contained in:
lukasIO 2026-01-13 16:40:33 +01:00
parent f80673aba8
commit b46fea16ae
7 changed files with 1208 additions and 3 deletions

127
app/dual-rooms/page.tsx Normal file
View File

@ -0,0 +1,127 @@
'use client';
import { useRouter } from 'next/navigation';
import React, { Suspense, useState } from 'react';
import Link from 'next/link';
import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils';
import styles from '../../styles/Home.module.css';
function DualRoomSetupContent() {
const router = useRouter();
const [e2ee, setE2ee] = useState(false);
const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64));
const [primaryRoomName, setPrimaryRoomName] = useState('');
const [secondaryRoomName, setSecondaryRoomName] = useState('');
const startDualRoomMeeting = () => {
const primary = primaryRoomName || generateRoomId();
const secondary = secondaryRoomName || generateRoomId();
if (e2ee) {
router.push(
`/dual-rooms/session?primary=${primary}&secondary=${secondary}#${encodePassphrase(sharedPassphrase)}`,
);
} else {
router.push(`/dual-rooms/session?primary=${primary}&secondary=${secondary}`);
}
};
return (
<main className={styles.main} data-lk-theme="default">
<div className="header">
<img src="/images/livekit-meet-home.svg" alt="LiveKit Meet" width="360" height="45" />
<h2>Dual Room Video Conference</h2>
<p style={{ margin: '1rem 0', maxWidth: '600px' }}>
Connect to two rooms simultaneously - perfect for monitoring multiple sessions or watching
screen shares from a separate room.
</p>
</div>
<div className={styles.tabContainer}>
<div className={styles.tabContent}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginBottom: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label htmlFor="primaryRoom">
<strong>Primary Room</strong> (with full controls)
</label>
<input
id="primaryRoom"
type="text"
placeholder="Leave empty for random room name"
value={primaryRoomName}
onChange={(ev) => setPrimaryRoomName(ev.target.value)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<label htmlFor="secondaryRoom">
<strong>Secondary Room</strong> (view-only, presentation mode)
</label>
<input
id="secondaryRoom"
type="text"
placeholder="Leave empty for random room name"
value={secondaryRoomName}
onChange={(ev) => setSecondaryRoomName(ev.target.value)}
/>
<small style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: '0.85rem' }}>
Audio muted by default. Camera/mic can be enabled on hover.
</small>
</div>
</div>
<button
style={{ marginTop: '1rem', width: '100%' }}
className="lk-button"
onClick={startDualRoomMeeting}
>
Start Dual Room Session
</button>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', marginTop: '1.5rem' }}>
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<input
id="use-e2ee"
type="checkbox"
checked={e2ee}
onChange={(ev) => setE2ee(ev.target.checked)}
/>
<label htmlFor="use-e2ee">Enable end-to-end encryption</label>
</div>
{e2ee && (
<div style={{ display: 'flex', flexDirection: 'row', gap: '1rem' }}>
<label htmlFor="passphrase">Passphrase</label>
<input
id="passphrase"
type="password"
value={sharedPassphrase}
onChange={(ev) => setSharedPassphrase(ev.target.value)}
/>
</div>
)}
</div>
</div>
</div>
<footer data-lk-theme="default">
<Link href="/" style={{ marginRight: '1rem' }}>
Back to Home
</Link>
Hosted on{' '}
<a href="https://livekit.io/cloud?ref=meet" rel="noopener">
LiveKit Cloud
</a>
. Source code on{' '}
<a href="https://github.com/livekit/meet?ref=meet" rel="noopener">
GitHub
</a>
.
</footer>
</main>
);
}
export default function DualRoomSetupPage() {
return (
<Suspense fallback="Loading">
<DualRoomSetupContent />
</Suspense>
);
}

View File

@ -0,0 +1,371 @@
'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>
);
}

View File

@ -0,0 +1,180 @@
'use client';
import React from 'react';
import {
GridLayout,
ParticipantTile,
RoomAudioRenderer,
ConnectionStateToast,
LayoutContextProvider,
FocusLayout,
CarouselLayout,
useLocalParticipant,
useTracks,
ControlBar,
Chat,
} from '@livekit/components-react';
import { useCreateLayoutContext } from '@livekit/components-react';
import { Track, RoomEvent } from 'livekit-client';
import { isTrackReference, isEqualTrackRef } from '@livekit/components-core';
import styles from '@/styles/DualRoom.module.css';
export interface PrimaryRoomViewProps {
chatMessageFormatter?: any;
SettingsComponent?: React.ComponentType;
}
export function PrimaryRoomView({
chatMessageFormatter,
SettingsComponent,
}: PrimaryRoomViewProps) {
const layoutContext = useCreateLayoutContext();
const { localParticipant } = useLocalParticipant();
const [widgetState, setWidgetState] = React.useState({
showChat: false,
unreadMessages: 0,
showSettings: false,
});
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false },
);
// Filter for screen share tracks
const screenShareTracks = tracks
.filter(isTrackReference)
.filter((track) => track.publication.source === Track.Source.ScreenShare);
// Auto-focus on screen share
const focusTrack = screenShareTracks.length > 0 ? screenShareTracks[0] : null;
// Get all tracks except local participant's camera
const remoteTracks = tracks.filter((track) => {
if (!isTrackReference(track)) {
// Placeholder - keep if not local participant
return track.participant.identity !== localParticipant.identity;
}
// Skip local participant's camera (but keep screen shares)
if (
track.participant.identity === localParticipant.identity &&
track.publication.source === Track.Source.Camera
) {
return false;
}
return true;
});
// Get local participant's camera track for PiP
const localCameraTrack = tracks.find(
(track) =>
isTrackReference(track) &&
track.participant.identity === localParticipant.identity &&
track.publication.source === Track.Source.Camera,
);
// Draggable PiP state
const [pipPosition, setPipPosition] = React.useState({ x: 20, y: 20 });
const [isDragging, setIsDragging] = React.useState(false);
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
const pipRef = React.useRef<HTMLDivElement>(null);
const handleMouseDown = React.useCallback(
(e: React.MouseEvent) => {
if (!pipRef.current) return;
const rect = pipRef.current.getBoundingClientRect();
setDragOffset({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
setIsDragging(true);
},
[pipRef],
);
React.useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
setPipPosition({
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, dragOffset]);
return (
<LayoutContextProvider
value={layoutContext}
onWidgetChange={(state) => setWidgetState(state)}
>
<div className={styles.primaryRoomInner}>
{!focusTrack ? (
<div className={styles.primaryGridView}>
<GridLayout tracks={remoteTracks}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div className={styles.primaryFocusView}>
<FocusLayout trackRef={focusTrack} />
<div className={styles.primaryCarousel}>
<CarouselLayout tracks={remoteTracks.filter((t) => !isEqualTrackRef(t, focusTrack))}>
<ParticipantTile />
</CarouselLayout>
</div>
</div>
)}
<ControlBar controls={{ chat: true, settings: !!SettingsComponent }} />
{/* Picture-in-Picture local participant */}
{localCameraTrack && isTrackReference(localCameraTrack) && (
<div
ref={pipRef}
className={`${styles.localPip} ${isDragging ? styles.dragging : ''}`}
style={{
left: `${pipPosition.x}px`,
top: `${pipPosition.y}px`,
}}
onMouseDown={handleMouseDown}
>
<ParticipantTile trackRef={localCameraTrack} />
</div>
)}
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
/>
{SettingsComponent && (
<div
className="lk-settings-menu-modal"
style={{ display: widgetState.showSettings ? 'block' : 'none' }}
>
<SettingsComponent />
</div>
)}
<RoomAudioRenderer />
<ConnectionStateToast />
</div>
</LayoutContextProvider>
);
}

View File

@ -0,0 +1,204 @@
'use client';
import React from 'react';
import {
GridLayout,
ParticipantTile,
RoomAudioRenderer,
RoomName,
ConnectionStateToast,
LayoutContextProvider,
FocusLayout,
CarouselLayout,
} from '@livekit/components-react';
import { useCreateLayoutContext } from '@livekit/components-react';
import { useTracks } from '@livekit/components-react';
import { Track, Room } from 'livekit-client';
import { isTrackReference } from '@livekit/components-core';
import styles from '@/styles/DualRoom.module.css';
export interface SecondaryRoomViewProps {
onToggleEnlarge: () => void;
isEnlarged: boolean;
room: Room;
}
export function SecondaryRoomView({ onToggleEnlarge, isEnlarged, room }: SecondaryRoomViewProps) {
const layoutContext = useCreateLayoutContext();
const [isAudioMuted, setIsAudioMuted] = React.useState(true); // Muted by default
const [isCameraEnabled, setIsCameraEnabled] = React.useState(false);
const [isMicEnabled, setIsMicEnabled] = React.useState(false);
const [showControls, setShowControls] = React.useState(false);
const tracks = useTracks(
[
{ source: Track.Source.ScreenShare, withPlaceholder: false },
{ source: Track.Source.Camera, withPlaceholder: true },
],
{ updateOnlyOn: [], onlySubscribed: false },
);
// Filter for screen share tracks
const screenShareTracks = tracks.filter(
(track) => isTrackReference(track) && track.publication.source === Track.Source.ScreenShare,
);
// Filter for camera tracks (participants)
const cameraTracks = tracks.filter((track) => {
if (!isTrackReference(track)) return true;
return track.publication?.source === Track.Source.Camera;
});
// Use first screen share track as focus, or null if none available
const focusTrack = screenShareTracks.length > 0 ? screenShareTracks[0] : null;
const toggleAudioMute = React.useCallback(() => {
setIsAudioMuted((prev) => !prev);
}, []);
const toggleCamera = React.useCallback(() => {
const newState = !isCameraEnabled;
setIsCameraEnabled(newState);
room.localParticipant.setCameraEnabled(newState).catch((error) => {
console.error('Failed to toggle camera:', error);
setIsCameraEnabled(!newState); // Revert on error
});
}, [isCameraEnabled, room]);
const toggleMic = React.useCallback(() => {
const newState = !isMicEnabled;
setIsMicEnabled(newState);
room.localParticipant.setMicrophoneEnabled(newState).catch((error) => {
console.error('Failed to toggle microphone:', error);
setIsMicEnabled(!newState); // Revert on error
});
}, [isMicEnabled, room]);
return (
<LayoutContextProvider value={layoutContext}>
<div
className={styles.secondaryRoomInner}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<div className={styles.secondaryRoomHeader}>
<div className={styles.secondaryRoomTitle}>
<RoomName />
</div>
<div className={styles.headerControls}>
<button
className={styles.controlButton}
onClick={toggleAudioMute}
title={isAudioMuted ? 'Unmute room audio' : 'Mute room audio'}
>
{isAudioMuted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"
fill="currentColor"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
fill="currentColor"
/>
</svg>
)}
</button>
<button className={styles.enlargeButton} onClick={onToggleEnlarge} title="Toggle size">
{isEnlarged ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 14h3v3h2v-5H4v2zm3-9H4v2h5V2H7v3zm6 9h3v-2h-5v5h2v-3zm3-12h-2v3h-3v2h5V2z"
fill="currentColor"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M2 9h5V4h2v7H2V9zm5 7H2v-2h7v7H7v-5zm7-7h5v2h-7V4h2v5zm5 5h-5v5h-2v-7h7v2z"
fill="currentColor"
/>
</svg>
)}
</button>
</div>
</div>
<div className={styles.secondaryRoomContent}>
{focusTrack ? (
<div className={styles.presentationMode}>
<div className={styles.screenShareView}>
<FocusLayout trackRef={focusTrack} />
</div>
{cameraTracks.length > 0 && (
<div className={styles.participantCarousel}>
<CarouselLayout tracks={cameraTracks} orientation="horizontal">
<ParticipantTile />
</CarouselLayout>
</div>
)}
</div>
) : (
<div className={styles.gridView}>
<GridLayout tracks={tracks}>
<ParticipantTile />
</GridLayout>
</div>
)}
{/* Hover controls for camera/mic */}
<div className={`${styles.publishControls} ${showControls ? styles.visible : ''}`}>
<button
className={`${styles.publishButton} ${isCameraEnabled ? styles.active : ''}`}
onClick={toggleCamera}
title={isCameraEnabled ? 'Disable camera' : 'Enable camera'}
>
{isCameraEnabled ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"
fill="currentColor"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M21 6.5l-4 4V7c0-.55-.45-1-1-1H9.82L21 17.18V6.5zM3.27 2L2 3.27 4.73 6H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.21 0 .39-.08.54-.18L19.73 21 21 19.73 3.27 2z"
fill="currentColor"
/>
</svg>
)}
</button>
<button
className={`${styles.publishButton} ${isMicEnabled ? styles.active : ''}`}
onClick={toggleMic}
title={isMicEnabled ? 'Disable microphone' : 'Enable microphone'}
>
{isMicEnabled ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.91-3c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"
fill="currentColor"
/>
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V20h2v-2.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"
fill="currentColor"
/>
</svg>
)}
</button>
</div>
</div>
<RoomAudioRenderer volume={isAudioMuted ? 0 : 1} />
<ConnectionStateToast />
</div>
</LayoutContextProvider>
);
}

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import { PageClientImpl } from './PageClientImpl';
import { isVideoCodec } from '@/lib/types';
export default async function DualRoomSessionPage({
searchParams,
}: {
searchParams: Promise<{
primary?: string;
secondary?: string;
region?: string;
hq?: string;
codec?: string;
}>;
}) {
const _searchParams = await searchParams;
const primaryRoom = _searchParams.primary || 'primary-room';
const secondaryRoom = _searchParams.secondary || 'secondary-room';
const codec =
typeof _searchParams.codec === 'string' && isVideoCodec(_searchParams.codec)
? _searchParams.codec
: 'vp9';
const hq = _searchParams.hq === 'true' ? true : false;
return (
<PageClientImpl
primaryRoomName={primaryRoom}
secondaryRoomName={secondaryRoom}
region={_searchParams.region}
hq={hq}
codec={codec}
/>
);
}

View File

@ -7,12 +7,13 @@ import styles from '../styles/Home.module.css';
function Tabs(props: React.PropsWithChildren<{}>) {
const searchParams = useSearchParams();
const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
const tab = searchParams?.get('tab');
const tabIndex = tab === 'custom' ? 1 : tab === 'dual' ? 2 : 0;
const router = useRouter();
function onTabSelected(index: number) {
const tab = index === 1 ? 'custom' : 'demo';
router.push(`/?tab=${tab}`);
const tabName = index === 1 ? 'custom' : index === 2 ? 'dual' : 'demo';
router.push(`/?tab=${tabName}`);
}
let tabs = React.Children.map(props.children, (child, index) => {
@ -160,6 +161,26 @@ function CustomConnectionTab(props: { label: string }) {
);
}
function DualRoomTab(_props: { label: string }) {
const router = useRouter();
return (
<div className={styles.tabContent}>
<p style={{ margin: 0 }}>
Connect to two rooms simultaneously - perfect for monitoring multiple sessions or watching
screen shares from a separate room.
</p>
<button
style={{ marginTop: '1rem' }}
className="lk-button"
onClick={() => router.push('/dual-rooms')}
>
Start Dual Room Session
</button>
</div>
);
}
export default function Page() {
return (
<>
@ -182,6 +203,7 @@ export default function Page() {
<Tabs>
<DemoMeetingTab label="Demo" />
<CustomConnectionTab label="Custom" />
<DualRoomTab label="Dual Room" />
</Tabs>
</Suspense>
</main>

265
styles/DualRoom.module.css Normal file
View File

@ -0,0 +1,265 @@
.dualRoomContainer {
display: flex;
height: 100vh;
width: 100vw;
gap: 0;
overflow: hidden;
}
.primaryRoom {
flex: 0 0 60%;
display: flex;
flex-direction: column;
min-width: 0;
height: 100%;
transition: flex 0.3s ease-in-out;
}
.secondaryRoom {
flex: 0 0 40%;
display: flex;
flex-direction: column;
border-left: 1px solid rgba(255, 255, 255, 0.1);
background: var(--lk-bg);
min-width: 0;
height: 100%;
transition: flex 0.3s ease-in-out;
}
/* Enlarged state - 50/50 split */
.dualRoomContainer[data-secondary-enlarged='true'] .primaryRoom {
flex: 0 0 50%;
}
.dualRoomContainer[data-secondary-enlarged='true'] .secondaryRoom {
flex: 0 0 50%;
}
.primaryRoomInner,
.secondaryRoomInner {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
.primaryGridView,
.primaryFocusView {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
.primaryCarousel {
height: 120px;
min-height: 120px;
width: 100%;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Local participant Picture-in-Picture */
.localPip {
position: absolute;
width: 200px;
height: 150px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
cursor: grab;
z-index: 100;
border: 2px solid rgba(255, 255, 255, 0.2);
transition: box-shadow 0.2s ease;
}
.localPip:hover {
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.7);
border-color: rgba(255, 255, 255, 0.4);
}
.localPip.dragging {
cursor: grabbing;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.8);
border-color: rgba(59, 130, 246, 0.8);
}
.secondaryRoomHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: var(--lk-bg2);
min-height: 60px;
}
.secondaryRoomTitle {
font-size: 1rem;
font-weight: 600;
color: var(--lk-foreground);
flex: 1;
}
.headerControls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.controlButton,
.enlargeButton {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: var(--lk-foreground);
cursor: pointer;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.controlButton:hover,
.enlargeButton:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
.controlButton:active,
.enlargeButton:active {
transform: scale(0.95);
}
.secondaryRoomContent {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.presentationMode {
flex: 1;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 0;
}
.screenShareView {
flex: 1;
display: flex;
width: 100%;
min-height: 0;
}
.participantCarousel {
height: 120px;
min-height: 120px;
width: 100%;
background: rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.gridView {
flex: 1;
display: flex;
width: 100%;
height: 100%;
}
/* Hover controls for camera/mic publishing */
.publishControls {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.publishControls.visible {
opacity: 1;
pointer-events: auto;
}
.publishButton {
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.publishButton:hover {
background: rgba(0, 0, 0, 0.85);
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.9);
}
.publishButton.active {
background: rgba(59, 130, 246, 0.8);
border-color: rgba(59, 130, 246, 1);
color: white;
}
.publishButton.active:hover {
background: rgba(59, 130, 246, 0.9);
}
/* Mobile responsive - stack vertically */
@media (max-width: 768px) {
.dualRoomContainer {
flex-direction: column;
}
.primaryRoom {
flex: 0 0 60%;
}
.secondaryRoom {
flex: 0 0 40%;
border-left: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.dualRoomContainer[data-secondary-enlarged='true'] .primaryRoom {
flex: 0 0 40%;
}
.dualRoomContainer[data-secondary-enlarged='true'] .secondaryRoom {
flex: 0 0 60%;
}
}
/* Tablet breakpoint */
@media (max-width: 1024px) and (min-width: 769px) {
.primaryRoom {
flex: 0 0 65%;
}
.secondaryRoom {
flex: 0 0 35%;
}
.dualRoomContainer[data-secondary-enlarged='true'] .primaryRoom {
flex: 0 0 50%;
}
.dualRoomContainer[data-secondary-enlarged='true'] .secondaryRoom {
flex: 0 0 50%;
}
}