From b46fea16ae4ce5f7af955c1eaa3dda535c53b254 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 13 Jan 2026 16:40:33 +0100 Subject: [PATCH] dual room --- app/dual-rooms/page.tsx | 127 +++++++ app/dual-rooms/session/PageClientImpl.tsx | 371 +++++++++++++++++++ app/dual-rooms/session/PrimaryRoomView.tsx | 180 +++++++++ app/dual-rooms/session/SecondaryRoomView.tsx | 204 ++++++++++ app/dual-rooms/session/page.tsx | 36 ++ app/page.tsx | 28 +- styles/DualRoom.module.css | 265 +++++++++++++ 7 files changed, 1208 insertions(+), 3 deletions(-) create mode 100644 app/dual-rooms/page.tsx create mode 100644 app/dual-rooms/session/PageClientImpl.tsx create mode 100644 app/dual-rooms/session/PrimaryRoomView.tsx create mode 100644 app/dual-rooms/session/SecondaryRoomView.tsx create mode 100644 app/dual-rooms/session/page.tsx create mode 100644 styles/DualRoom.module.css diff --git a/app/dual-rooms/page.tsx b/app/dual-rooms/page.tsx new file mode 100644 index 0000000..159b789 --- /dev/null +++ b/app/dual-rooms/page.tsx @@ -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 ( +
+
+ LiveKit Meet +

Dual Room Video Conference

+

+ Connect to two rooms simultaneously - perfect for monitoring multiple sessions or watching + screen shares from a separate room. +

+
+
+
+
+
+ + setPrimaryRoomName(ev.target.value)} + /> +
+
+ + setSecondaryRoomName(ev.target.value)} + /> + + Audio muted by default. Camera/mic can be enabled on hover. + +
+
+ + + +
+
+ setE2ee(ev.target.checked)} + /> + +
+ {e2ee && ( +
+ + setSharedPassphrase(ev.target.value)} + /> +
+ )} +
+
+
+ +
+ ); +} + +export default function DualRoomSetupPage() { + return ( + + + + ); +} diff --git a/app/dual-rooms/session/PageClientImpl.tsx b/app/dual-rooms/session/PageClientImpl.tsx new file mode 100644 index 0000000..c2ca900 --- /dev/null +++ b/app/dual-rooms/session/PageClientImpl.tsx @@ -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( + 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 ( +
+ {connectionDetails === undefined || preJoinChoices === undefined ? ( +
+ +
+ ) : ( + + )} +
+ ); +} + +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 ( +
+
+ +
+ + + + +
+
+
+ +
+ + + +
+
+ ); +} diff --git a/app/dual-rooms/session/PrimaryRoomView.tsx b/app/dual-rooms/session/PrimaryRoomView.tsx new file mode 100644 index 0000000..fe22a17 --- /dev/null +++ b/app/dual-rooms/session/PrimaryRoomView.tsx @@ -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(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 ( + setWidgetState(state)} + > +
+ {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ +
+ !isEqualTrackRef(t, focusTrack))}> + + +
+
+ )} + + + + {/* Picture-in-Picture local participant */} + {localCameraTrack && isTrackReference(localCameraTrack) && ( +
+ +
+ )} + + + + {SettingsComponent && ( +
+ +
+ )} + + + +
+
+ ); +} diff --git a/app/dual-rooms/session/SecondaryRoomView.tsx b/app/dual-rooms/session/SecondaryRoomView.tsx new file mode 100644 index 0000000..63eb400 --- /dev/null +++ b/app/dual-rooms/session/SecondaryRoomView.tsx @@ -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 ( + +
setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + > +
+
+ +
+
+ + +
+
+ +
+ {focusTrack ? ( +
+
+ +
+ {cameraTracks.length > 0 && ( +
+ + + +
+ )} +
+ ) : ( +
+ + + +
+ )} + + {/* Hover controls for camera/mic */} +
+ + +
+
+ + + +
+
+ ); +} diff --git a/app/dual-rooms/session/page.tsx b/app/dual-rooms/session/page.tsx new file mode 100644 index 0000000..99da099 --- /dev/null +++ b/app/dual-rooms/session/page.tsx @@ -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 ( + + ); +} diff --git a/app/page.tsx b/app/page.tsx index d23d536..9bd04c1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 ( +
+

+ Connect to two rooms simultaneously - perfect for monitoring multiple sessions or watching + screen shares from a separate room. +

+ +
+ ); +} + export default function Page() { return ( <> @@ -182,6 +203,7 @@ export default function Page() { + diff --git a/styles/DualRoom.module.css b/styles/DualRoom.module.css new file mode 100644 index 0000000..50341d0 --- /dev/null +++ b/styles/DualRoom.module.css @@ -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%; + } +}