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
|
- name: Use Node.js 22
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 24
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- 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<{}>) {
|
function Tabs(props: React.PropsWithChildren<{}>) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const tab = searchParams?.get('tab');
|
const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0;
|
||||||
const tabIndex = tab === 'custom' ? 1 : tab === 'dual' ? 2 : 0;
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
function onTabSelected(index: number) {
|
function onTabSelected(index: number) {
|
||||||
const tabName = index === 1 ? 'custom' : index === 2 ? 'dual' : 'demo';
|
const tab = index === 1 ? 'custom' : 'demo';
|
||||||
router.push(`/?tab=${tabName}`);
|
router.push(`/?tab=${tab}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let tabs = React.Children.map(props.children, (child, index) => {
|
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() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -203,7 +182,6 @@ export default function Page() {
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<DemoMeetingTab label="Demo" />
|
<DemoMeetingTab label="Demo" />
|
||||||
<CustomConnectionTab label="Custom" />
|
<CustomConnectionTab label="Custom" />
|
||||||
<DualRoomTab label="Dual Room" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
13
package.json
13
package.json
@ -14,13 +14,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@datadog/browser-logs": "^5.23.3",
|
"@datadog/browser-logs": "^5.23.3",
|
||||||
"@livekit/components-react": "2.9.16",
|
"@livekit/components-react": "2.9.19",
|
||||||
"@livekit/components-core": "0.12.12",
|
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/krisp-noise-filter": "0.3.4",
|
"@livekit/krisp-noise-filter": "0.4.1",
|
||||||
"@livekit/track-processors": "^0.6.0",
|
"@livekit/track-processors": "^0.7.0",
|
||||||
"livekit-client": "2.16.0",
|
"livekit-client": "2.17.2",
|
||||||
"livekit-server-sdk": "2.14.2",
|
"livekit-server-sdk": "2.15.0",
|
||||||
"next": "15.2.8",
|
"next": "15.2.8",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
@ -28,7 +27,7 @@
|
|||||||
"tinykeys": "^3.0.0"
|
"tinykeys": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "22.19.1",
|
"@types/node": "24.10.13",
|
||||||
"@types/react": "18.3.27",
|
"@types/react": "18.3.27",
|
||||||
"@types/react-dom": "18.3.7",
|
"@types/react-dom": "18.3.7",
|
||||||
"eslint": "9.39.1",
|
"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