Compare commits
4 Commits
lukas/brea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cee3ed06 | ||
|
|
2220072d47 | ||
|
|
392ca136de | ||
|
|
3a75f3222f |
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
@ -16,7 +16,7 @@ jobs:
|
||||
- name: Use Node.js 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,371 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,181 +0,0 @@
|
||||
'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}
|
||||
// @ts-ignore
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,234 +0,0 @@
|
||||
'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 [volume, setVolume] = React.useState(1); // Volume level (0-1)
|
||||
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 handleVolumeChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (newVolume === 0) {
|
||||
setIsAudioMuted(true);
|
||||
} else if (isAudioMuted) {
|
||||
setIsAudioMuted(false);
|
||||
}
|
||||
}, [isAudioMuted]);
|
||||
|
||||
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}>
|
||||
<div className={styles.volumeControl}>
|
||||
<button
|
||||
className={styles.controlButton}
|
||||
onClick={toggleAudioMute}
|
||||
title={isAudioMuted ? 'Unmute room audio' : 'Mute room audio'}
|
||||
>
|
||||
{isAudioMuted || volume === 0 ? (
|
||||
<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>
|
||||
) : volume < 0.5 ? (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M7 9v6h4l5 5V4l-5 5H7z"
|
||||
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>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className={styles.volumeSlider}
|
||||
title={`Volume: ${Math.round(volume * 100)}%`}
|
||||
/>
|
||||
</div>
|
||||
<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 : volume} />
|
||||
<ConnectionStateToast />
|
||||
</div>
|
||||
</LayoutContextProvider>
|
||||
);
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
app/page.tsx
28
app/page.tsx
@ -7,13 +7,12 @@ import styles from '../styles/Home.module.css';
|
||||
|
||||
function Tabs(props: React.PropsWithChildren<{}>) {
|
||||
const searchParams = useSearchParams();
|
||||
const tab = searchParams?.get('tab');
|
||||
const tabIndex = tab === 'custom' ? 1 : tab === 'dual' ? 2 : 0;
|
||||
const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
|
||||
|
||||
const router = useRouter();
|
||||
function onTabSelected(index: number) {
|
||||
const tabName = index === 1 ? 'custom' : index === 2 ? 'dual' : 'demo';
|
||||
router.push(`/?tab=${tabName}`);
|
||||
const tab = index === 1 ? 'custom' : 'demo';
|
||||
router.push(`/?tab=${tab}`);
|
||||
}
|
||||
|
||||
let tabs = React.Children.map(props.children, (child, index) => {
|
||||
@ -161,26 +160,6 @@ 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 (
|
||||
<>
|
||||
@ -203,7 +182,6 @@ export default function Page() {
|
||||
<Tabs>
|
||||
<DemoMeetingTab label="Demo" />
|
||||
<CustomConnectionTab label="Custom" />
|
||||
<DualRoomTab label="Dual Room" />
|
||||
</Tabs>
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
13
package.json
13
package.json
@ -14,13 +14,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@datadog/browser-logs": "^5.23.3",
|
||||
"@livekit/components-react": "2.9.16",
|
||||
"@livekit/components-core": "0.12.12",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/krisp-noise-filter": "0.3.4",
|
||||
"@livekit/track-processors": "^0.6.0",
|
||||
"livekit-client": "2.16.0",
|
||||
"livekit-server-sdk": "2.14.2",
|
||||
"@livekit/krisp-noise-filter": "0.4.1",
|
||||
"@livekit/track-processors": "^0.7.0",
|
||||
"livekit-client": "2.17.2",
|
||||
"livekit-server-sdk": "2.15.0",
|
||||
"next": "15.2.8",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
@ -28,7 +27,7 @@
|
||||
"tinykeys": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.1",
|
||||
"@types/node": "24.10.13",
|
||||
"@types/react": "18.3.27",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"eslint": "9.39.1",
|
||||
|
||||
1030
pnpm-lock.yaml
generated
1030
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,318 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.volumeControl {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.volumeSlider:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.volumeSlider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--lk-foreground);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.volumeSlider::-webkit-slider-thumb:hover {
|
||||
background: white;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.volumeSlider::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--lk-foreground);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.volumeSlider::-moz-range-thumb:hover {
|
||||
background: white;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user