Разработка системы групповых видеоконференций на сайте
Групповые видеоконференции — это 3–50+ участников, управление микрофоном/камерой ведущим, рука вверх, брейкаут-комнаты, чат, демонстрация экрана, запись. Всё это требует продуманной архитектуры как на стороне WebRTC-инфраструктуры, так и на UI.
Выбор инфраструктуры по масштабу
| Участников | Рекомендованное решение |
|---|---|
| 2–10 | LiveKit (SFU), Daily.co |
| 10–50 | LiveKit с Simulcast, 100ms |
| 50–1000 | LiveKit Broadcast, Agora, Amazon Chime |
| 1000+ | HLS-трансляция, не WebRTC |
LiveKit — рекомендованная основа
npm install livekit-server-sdk # сервер
npm install @livekit/components-react livekit-client # клиент
Создание конференции
import { RoomServiceClient, AccessToken, RoomOptions } from 'livekit-server-sdk';
const svc = new RoomServiceClient(
process.env.LIVEKIT_URL!,
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!
);
async function createConference(conferenceId: string, options: {
maxParticipants?: number;
enableRecording?: boolean;
}): Promise<void> {
await svc.createRoom({
name: `conf-${conferenceId}`,
maxParticipants: options.maxParticipants ?? 50,
emptyTimeout: 300, // 5 мин до закрытия пустой комнаты
metadata: JSON.stringify({ conferenceId, createdAt: new Date().toISOString() }),
} as RoomOptions);
}
function generateParticipantToken(
roomName: string,
userId: string,
displayName: string,
role: 'host' | 'moderator' | 'participant' | 'viewer'
): string {
const at = new AccessToken(
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!,
{ identity: userId, name: displayName, ttl: 4 * 60 * 60 }
);
at.addGrant({
roomJoin: true,
room: roomName,
canPublish: role !== 'viewer',
canSubscribe: true,
canPublishData: true,
roomAdmin: role === 'host',
// Moderator может mute других
hidden: false,
});
return at.toJwt();
}
Управление участниками с сервера
// Заглушить конкретного участника (по запросу ведущего)
app.post('/api/conferences/:roomName/mute/:participantId', authenticate, async (req, res) => {
const conference = await db.conferences.findByRoomName(req.params.roomName);
if (conference.hostId !== req.user.id) return res.status(403).end();
await svc.mutePublishedTrack(
req.params.roomName,
req.params.participantId,
'microphone-track',
true
);
// Уведомить через Data channel
await svc.sendData(
req.params.roomName,
Buffer.from(JSON.stringify({ type: 'muted_by_host', targetId: req.params.participantId })),
[req.params.participantId]
);
res.json({ ok: true });
});
// Удалить участника
app.delete('/api/conferences/:roomName/participants/:participantId', authenticate, async (req, res) => {
await svc.removeParticipant(req.params.roomName, req.params.participantId);
res.json({ ok: true });
});
React компонент конференции
import {
LiveKitRoom,
VideoConference,
useLocalParticipant,
useRoomContext,
useParticipants,
Chat,
RoomAudioRenderer,
ControlBar,
} from '@livekit/components-react';
import '@livekit/components-styles';
function GroupConference({ token, roomName }: { token: string; roomName: string }) {
return (
<LiveKitRoom
token={token}
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
video={true}
audio={true}
style={{ height: '100vh' }}
data-lk-theme="default"
>
<ConferenceLayout roomName={roomName} />
<RoomAudioRenderer />
</LiveKitRoom>
);
}
function ConferenceLayout({ roomName }: { roomName: string }) {
const participants = useParticipants();
const { localParticipant } = useLocalParticipant();
const room = useRoomContext();
const [showChat, setShowChat] = useState(false);
const [raisedHands, setRaisedHands] = useState<Set<string>>(new Set());
// Поднять руку через Data channel
const toggleRaiseHand = async () => {
const isRaised = raisedHands.has(localParticipant.identity);
const message = { type: 'hand_raise', raised: !isRaised, identity: localParticipant.identity };
await room.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify(message)),
{ reliable: true }
);
};
// Получать Data messages
useEffect(() => {
const handler = (payload: Uint8Array) => {
const msg = JSON.parse(new TextDecoder().decode(payload));
if (msg.type === 'hand_raise') {
setRaisedHands(prev => {
const next = new Set(prev);
msg.raised ? next.add(msg.identity) : next.delete(msg.identity);
return next;
});
}
};
room.on('dataReceived', handler);
return () => { room.off('dataReceived', handler); };
}, [room]);
return (
<div className="flex h-full">
{/* Основная сетка видео */}
<div className="flex-1 relative">
<div className={`grid ${getGridLayout(participants.length)} gap-2 p-4 h-full`}>
{participants.map(p => (
<ParticipantTile
key={p.identity}
participant={p}
hasRaisedHand={raisedHands.has(p.identity)}
/>
))}
</div>
{/* Панель управления */}
<div className="absolute bottom-0 left-0 right-0 flex justify-center pb-4 gap-3">
<ControlBar
controls={{
microphone: true,
camera: true,
screenShare: true,
leave: true,
}}
/>
<button
onClick={toggleRaiseHand}
className={`p-3 rounded-full ${raisedHands.has(localParticipant.identity) ? 'bg-yellow-500' : 'bg-gray-700'}`}
>
✋
</button>
<button onClick={() => setShowChat(!showChat)} className="p-3 rounded-full bg-gray-700">
💬 {participants.length}
</button>
</div>
</div>
{/* Чат */}
{showChat && (
<div className="w-80 border-l border-gray-700">
<Chat />
</div>
)}
</div>
);
}
function getGridLayout(count: number): string {
if (count <= 1) return 'grid-cols-1';
if (count <= 4) return 'grid-cols-2';
if (count <= 9) return 'grid-cols-3';
return 'grid-cols-4';
}
Simulcast для адаптивного качества
LiveKit автоматически публикует 3 слоя качества (high/medium/low). Подписчики получают нужный слой в зависимости от пропускной способности и размера плитки:
// На стороне клиента — явно задать качество для конкретного участника
participant.setTrackSubscriptionPermissions(true, [
{ trackSid: videoTrack.sid, quality: VideoQuality.MEDIUM }
]);
Брейкаут-комнаты
async function createBreakoutRooms(mainRoomName: string, groups: string[][]) {
const breakoutRooms = await Promise.all(
groups.map((group, i) =>
createConference(`${mainRoomName}-breakout-${i}`, { maxParticipants: group.length + 2 })
)
);
// Уведомить участников об их комнате
for (let i = 0; i < groups.length; i++) {
for (const participantId of groups[i]) {
const token = generateParticipantToken(
`conf-${mainRoomName}-breakout-${i}`,
participantId,
'',
'participant'
);
await svc.sendData(
`conf-${mainRoomName}`,
Buffer.from(JSON.stringify({ type: 'breakout_invite', token, roomIndex: i })),
[participantId]
);
}
}
}
Сроки
Базовая групповая конференция с LiveKit + React компонентами — 1 неделя. С брейкаут-комнатами, поднятием руки, записью и управлением участниками — 2–3 недели.







