'use client'; /** * LiveKit видеокомната — вариант из коробки (@livekit/components-react) */ import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; /** Ошибка LiveKit updatePages (placeholder/track race) — не выкидываем пользователя из занятия. */ function isLiveKitLayoutError(error: unknown): boolean { const msg = error instanceof Error ? error.message : String(error); return ( msg.includes('Element not part of the array') || msg.includes('updatePages') || msg.includes('_placeholder not in') ); } class LiveKitLayoutErrorBoundary extends React.Component< { children: React.ReactNode }, { error: unknown; recoverKey: number } > { state = { error: null as unknown, recoverKey: 0 }; static getDerivedStateFromError(error: unknown) { return { error }; } componentDidCatch(error: unknown) { if (isLiveKitLayoutError(error)) { window.setTimeout(() => { this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 })); }, 100); } } render() { if (this.state.error && !isLiveKitLayoutError(this.state.error)) { throw this.state.error; } if (this.state.error) { return (
); } return {this.props.children}; } } import { useRouter, useSearchParams } from 'next/navigation'; import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react'; import { ExitLessonModal } from '@/components/livekit/ExitLessonModal'; import { Track, RoomEvent, VideoPresets } from 'livekit-client'; /** 2K (1440p) — разрешение и кодирование для высокого качества при хорошем канале */ const PRESET_2K = { resolution: { width: 2560, height: 1440 }, encoding: { maxBitrate: 6_000_000, maxFramerate: 30 } as const, }; import { isTrackReference } from '@livekit/components-core'; import '@/styles/livekit-components.css'; import '@/styles/livekit-theme.css'; import { getLesson } from '@/api/schedule'; import { participantConnected } from '@/api/livekit'; import type { Lesson } from '@/api/schedule'; import { getOrCreateLessonBoard } from '@/api/board'; /** Извлечь board_id и is_mentor из metadata LiveKit JWT (без верификации). */ function getTokenMetadata(token: string | null): { board_id?: string; is_mentor?: boolean } { if (!token) return {}; try { const parts = token.split('.'); if (parts.length !== 3) return {}; const payload = JSON.parse( atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')) ) as { metadata?: string }; const meta = payload.metadata; if (!meta || typeof meta !== 'string') return {}; const parsed = JSON.parse(meta) as { board_id?: string; is_mentor?: boolean }; return { board_id: parsed?.board_id ?? undefined, is_mentor: parsed?.is_mentor === true, }; } catch { return {}; } } import { WhiteboardIframe } from '@/components/board/WhiteboardIframe'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { getOrCreateLessonChat } from '@/api/chat'; import type { Chat } from '@/api/chat'; import { ChatWindow } from '@/components/chat/ChatWindow'; import { useAuth } from '@/contexts/AuthContext'; import { useOnboarding } from '@/contexts/OnboardingContext'; import { getAvatarUrl } from '@/api/profile'; import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar'; import { getNavBadges } from '@/api/navBadges'; import type { NavBadges } from '@/api/navBadges'; const CHAT_PANEL_WIDTH = 420; /** Камера собеседника в углу при открытой доске; сдвигается влево, когда открыт чат */ function RemoteParticipantPiP({ chatOpen }: { chatOpen: boolean }) { const tracks = useTracks( [Track.Source.Camera, Track.Source.ScreenShare], { onlySubscribed: true } ); const remoteRef = tracks.find( (ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal ); if (!remoteRef || !isTrackReference(remoteRef)) return null; return (
); } const AVATAR_IMG_CLASS = 'lk-participant-avatar-img'; /** * Подставляет аватары в плейсхолдер, когда камера выключена: * — у удалённых участников (собеседник видит их фото); * — у локального участника (своё фото вижу я и собеседник). * GET /api/users//avatar_url/ */ function RemoteParticipantAvatarPlaceholder() { const { user } = useAuth(); const remoteParticipants = useRemoteParticipants(); const [avatarUrls, setAvatarUrls] = useState>(new Map()); const [localAvatarUrl, setLocalAvatarUrl] = useState(null); const injectedRef = useRef>(new WeakSet()); const identityKey = remoteParticipants.map((p) => p.identity).sort().join(','); // Аватар собеседников useEffect(() => { if (remoteParticipants.length === 0) { setAvatarUrls(new Map()); return; } let cancelled = false; const map = new Map(); Promise.all( remoteParticipants.map(async (p) => { const id = p.identity; const url = await getAvatarUrl(id); return { id, url } as const; }) ).then((results) => { if (cancelled) return; results.forEach(({ id, url }) => { if (url) map.set(id, url); }); setAvatarUrls(new Map(map)); }); return () => { cancelled = true; }; }, [identityKey]); // Свой аватар (чтобы видел и я, и собеседник, когда камера выключена) useEffect(() => { const id = user?.id; if (id == null) { setLocalAvatarUrl(null); return; } let cancelled = false; getAvatarUrl(String(id)).then((url) => { if (!cancelled) setLocalAvatarUrl(url); }); return () => { cancelled = true; }; }, [user?.id]); const runInject = React.useCallback(() => { const injectInto = (placeholder: Element, url: string | null) => { let img = placeholder.querySelector(`img.${AVATAR_IMG_CLASS}`) as HTMLImageElement | null; if (url) { if (!img) { img = document.createElement('img'); img.className = AVATAR_IMG_CLASS; img.alt = ''; placeholder.appendChild(img); } if (img.src !== url) img.src = url; injectedRef.current.add(placeholder); } else { if (img) img.remove(); } }; // Плитки собеседников const remotePlaceholders = document.querySelectorAll( '.lk-participant-tile[data-lk-local-participant="false"] .lk-participant-placeholder' ); const urls = remoteParticipants.map((p) => avatarUrls.get(p.identity) ?? null); remotePlaceholders.forEach((placeholder, i) => { const url = urls[Math.min(i, urls.length - 1)] ?? null; injectInto(placeholder, url); }); // Своя плитка — свой аватар (вижу я и собеседник) const localPlaceholders = document.querySelectorAll( '.lk-participant-tile[data-lk-local-participant="true"] .lk-participant-placeholder' ); localPlaceholders.forEach((placeholder) => injectInto(placeholder, localAvatarUrl)); }, [remoteParticipants, avatarUrls, localAvatarUrl]); useLayoutEffect(() => { runInject(); const t1 = setTimeout(runInject, 300); const t2 = setTimeout(runInject, 1000); return () => { clearTimeout(t1); clearTimeout(t2); }; }, [runInject]); return null; } const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed'; const LS_AUDIO_ENABLED = 'videoConference_audioEnabled'; const LS_VIDEO_ENABLED = 'videoConference_videoEnabled'; /** * Оверлей «Разрешить звук» — показываем только при первом посещении. * После клика сохраняем в localStorage, чтобы не спрашивать постоянно. */ function StartAudioOverlay() { const room = useRoomContext(); const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} }); const [dismissed, setDismissed] = useState(() => { try { return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true'; } catch { return false; } }); useEffect(() => { if (canPlayAudio) { try { localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true'); } catch {} } }, [canPlayAudio]); if (canPlayAudio || dismissed) return null; const handleClick = () => { try { localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true'); } catch {} setDismissed(true); mergedProps.onClick?.(); }; return (

Чтобы слышать собеседника, разрешите воспроизведение звука

); } function PreJoinScreen({ onJoin, onCancel, }: { onJoin: (audio: boolean, video: boolean) => void; onCancel: () => void; }) { const [audioEnabled, setAudioEnabled] = useState(true); const [videoEnabled, setVideoEnabled] = useState(true); const [preview, setPreview] = useState(null); const videoRef = React.useRef(null); // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS) useEffect(() => { const saved = getSavedAudioVideo(); setAudioEnabled(saved.audio); setVideoEnabled(saved.video); }, []); useEffect(() => { if (!videoEnabled) return; let stream: MediaStream | null = null; navigator.mediaDevices .getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false }) .then((s) => { stream = s; setPreview(s); if (videoRef.current) videoRef.current.srcObject = s; }) .catch(() => {}); return () => { stream?.getTracks().forEach((t) => t.stop()); setPreview(null); }; }, [videoEnabled]); const handleJoin = () => { try { localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled)); localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled)); console.log(`[LiveKit аудио/видео] PreJoin handleJoin: audio=${audioEnabled}, video=${videoEnabled} → localStorage`); } catch {} preview?.getTracks().forEach((t) => t.stop()); onJoin(audioEnabled, videoEnabled); }; return (

Настройки перед входом

Настройте камеру и микрофон

{videoEnabled ? (
Микрофон
Камера
); } type RoomContentProps = { lessonId: number | null; boardId: string | null; boardLoading: boolean; showBoard: boolean; setShowBoard: (v: boolean) => void; userDisplayName: string; }; function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard, userDisplayName }: RoomContentProps) { const room = useRoomContext(); const router = useRouter(); const { user } = useAuth(); const [showPlatformChat, setShowPlatformChat] = useState(false); const [lessonChat, setLessonChat] = useState(null); const [lessonChatLoading, setLessonChatLoading] = useState(false); const [showExitModal, setShowExitModal] = useState(false); const [showNavMenu, setShowNavMenu] = useState(false); const [navBadges, setNavBadges] = useState(null); useEffect(() => { if (!user) return; getNavBadges().then(setNavBadges).catch(() => setNavBadges(null)); }, [user]); // Фиксируем подключение ментора/студента для метрик (lessonId — резервный поиск комнаты) useEffect(() => { const onConnected = () => { if (room.name || lessonId) participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {}); }; room.on(RoomEvent.Connected, onConnected); if (room.state === 'connected' && (room.name || lessonId)) participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {}); return () => { room.off(RoomEvent.Connected, onConnected); }; }, [room]); useEffect(() => { if (!showPlatformChat || !lessonId) { if (!showPlatformChat) setLessonChat(null); return; } setLessonChatLoading(true); getOrCreateLessonChat(lessonId) .then((c) => setLessonChat(c)) .catch(() => setLessonChat(null)) .finally(() => setLessonChatLoading(false)); }, [showPlatformChat, lessonId]); // Вставляем бургер (слева от микрофона) и кнопку «Выйти» в панель LiveKit useEffect(() => { if (showBoard) return; const id = setTimeout(() => { const bar = document.querySelector('.lk-control-bar'); if (!bar) return; if (!bar.querySelector('.lk-burger-button')) { const burger = document.createElement('button'); burger.type = 'button'; burger.className = 'lk-button lk-burger-button'; burger.title = 'Меню'; burger.innerHTML = 'menu'; burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click'))); bar.insertBefore(burger, bar.firstChild); } if (!bar.querySelector('.lk-custom-exit-button')) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'lk-button lk-custom-exit-button'; btn.setAttribute('data-lk-source', 'disconnect'); btn.title = 'Выйти'; btn.innerHTML = 'logout'; btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click'))); bar.appendChild(btn); } }, 800); return () => clearTimeout(id); }, [showBoard]); useEffect(() => { const handler = () => setShowNavMenu((v) => !v); window.addEventListener('livekit-burger-click', handler); return () => window.removeEventListener('livekit-burger-click', handler); }, []); useEffect(() => { const handler = () => { if (user?.role === 'mentor') { setShowExitModal(true); } else { room.disconnect(); } }; window.addEventListener('livekit-exit-click', handler); return () => window.removeEventListener('livekit-exit-click', handler); }, [user?.role, room]); // Сохраняем в localStorage при переключении микрофона/камеры + логи для отладки useEffect(() => { const lp = room.localParticipant; if (!lp) return; const log = (source: string, audio: boolean, video: boolean) => { console.log(`[LiveKit аудио/видео] ${source}: audio=${audio}, video=${video} → localStorage.${LS_AUDIO_ENABLED}=${audio}, ${LS_VIDEO_ENABLED}=${video}`); }; const saveFromState = (source: string) => { const audio = lp.isMicrophoneEnabled; const video = lp.isCameraEnabled; try { localStorage.setItem(LS_AUDIO_ENABLED, String(audio)); localStorage.setItem(LS_VIDEO_ENABLED, String(video)); log(source, audio, video); } catch (e) { console.error('[LiveKit аудио/видео] Ошибка сохранения:', e); } }; const onTrackMuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => { if (participant?.sid !== lp.sid) return; try { if (pub?.source === Track.Source.Microphone) { localStorage.setItem(LS_AUDIO_ENABLED, 'false'); log('TrackMuted(Microphone)', false, lp.isCameraEnabled); } else if (pub?.source === Track.Source.Camera) { localStorage.setItem(LS_VIDEO_ENABLED, 'false'); log('TrackMuted(Camera)', lp.isMicrophoneEnabled, false); } else { saveFromState('TrackMuted(?)'); } } catch (e) { console.error('[LiveKit аудио/видео] TrackMuted ошибка:', e); } }; const onTrackUnmuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => { if (participant?.sid !== lp.sid) return; try { if (pub?.source === Track.Source.Microphone) { localStorage.setItem(LS_AUDIO_ENABLED, 'true'); log('TrackUnmuted(Microphone)', true, lp.isCameraEnabled); } else if (pub?.source === Track.Source.Camera) { localStorage.setItem(LS_VIDEO_ENABLED, 'true'); log('TrackUnmuted(Camera)', lp.isMicrophoneEnabled, true); } else { saveFromState('TrackUnmuted(?)'); } } catch (e) { console.error('[LiveKit аудио/видео] TrackUnmuted ошибка:', e); } }; const onLocalTrackPublished = () => saveFromState('LocalTrackPublished'); room.on(RoomEvent.TrackMuted, onTrackMuted); room.on(RoomEvent.TrackUnmuted, onTrackUnmuted); room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished); saveFromState('init'); return () => { room.off(RoomEvent.TrackMuted, onTrackMuted); room.off(RoomEvent.TrackUnmuted, onTrackUnmuted); room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished); /* Не сохраняем при unmount/Disconnected — треки уже удалены, lp.isMicrophoneEnabled вернёт false */ }; }, [room]); return (
{/* Boundary только вокруг VideoConference — при reset доска не перемонтируется */}
message} />
{/* Сайдбар, PiP, бургер и навигация в Portal */} {typeof document !== 'undefined' && createPortal( <> {showBoard && } {/* Бургер при открытой доске — панель скрыта, показываем свой бургер */} {showBoard && ( )} {/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */} {showNavMenu && ( <>
setShowNavMenu(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }} /> setShowNavMenu(false)} /> )}
{lessonId != null && ( )}
, document.body )} {/* Панель чата сервиса (не LiveKit) */} {showPlatformChat && (
{lessonChatLoading ? (
progress_activity
) : ( setShowPlatformChat(false)} /> )}
)} setShowExitModal(false)} onExit={() => room.disconnect()} />
); } const SS_PREJOIN_DONE = 'livekit_prejoin_done'; /** PreJoin — показываем при первом заходе. После входа в комнату или перезагрузки — пропускаем (sessionStorage). */ function getInitialShowPreJoin(searchParams: URLSearchParams): boolean { try { if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') { return false; } return searchParams.get('skip_prejoin') !== '1'; } catch { return true; } } function getSavedAudioVideo(): { audio: boolean; video: boolean } { try { const rawA = localStorage.getItem(LS_AUDIO_ENABLED); const rawV = localStorage.getItem(LS_VIDEO_ENABLED); const audio = rawA !== 'false'; const video = rawV !== 'false'; console.log(`[LiveKit аудио/видео] Чтение из localStorage: ${LS_AUDIO_ENABLED}=${rawA} → audio=${audio}, ${LS_VIDEO_ENABLED}=${rawV} → video=${video}`); return { audio, video }; } catch (e) { console.error('[LiveKit аудио/видео] Ошибка чтения localStorage:', e); return { audio: true, video: true }; } } export default function LiveKitRoomContent() { const router = useRouter(); const searchParams = useSearchParams(); const accessToken = searchParams.get('token'); const lessonIdParam = searchParams.get('lesson_id'); const { user } = useAuth(); const onboarding = useOnboarding(); const [serverUrl, setServerUrl] = useState(''); const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams)); const [audioEnabled, setAudioEnabled] = useState(true); const [videoEnabled, setVideoEnabled] = useState(true); const [avReady, setAvReady] = useState(false); // Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS) useEffect(() => { const saved = getSavedAudioVideo(); console.log('[LiveKit аудио/видео] useEffect: загрузили из localStorage, устанавливаем state:', saved); setAudioEnabled(saved.audio); setVideoEnabled(saved.video); setAvReady(true); }, []); const [lessonCompleted, setLessonCompleted] = useState(false); const [effectiveLessonId, setEffectiveLessonId] = useState(null); const [boardId, setBoardId] = useState(null); const [boardLoading, setBoardLoading] = useState(false); const boardPollRef = useRef | null>(null); const [isMentor, setIsMentor] = useState(false); const [showBoard, setShowBoard] = useState(false); const [userDisplayName, setUserDisplayName] = useState('Пользователь'); // Доска и is_mentor из metadata LiveKit токена или getOrCreateLessonBoard; при отсутствии доски — опрос раз в 10 с useEffect(() => { const meta = getTokenMetadata(accessToken); if (meta.is_mentor === true) setIsMentor(true); if (meta.board_id) { setBoardId(meta.board_id); setBoardLoading(false); return; } if (!effectiveLessonId) { setBoardLoading(false); return; } let cancelled = false; const stopPoll = () => { if (boardPollRef.current) { clearInterval(boardPollRef.current); boardPollRef.current = null; } }; setBoardLoading(true); getOrCreateLessonBoard(effectiveLessonId) .then((b) => { if (!cancelled) { setBoardId(b.board_id); stopPoll(); } }) .catch(() => {}) .finally(() => { if (!cancelled) setBoardLoading(false); }); boardPollRef.current = setInterval(() => { if (cancelled) return; getOrCreateLessonBoard(effectiveLessonId) .then((b) => { if (!cancelled) { setBoardId(b.board_id); stopPoll(); } }) .catch(() => {}); }, 10_000); return () => { cancelled = true; stopPoll(); }; }, [accessToken, effectiveLessonId]); // Fallback: is_mentor из API урока (lesson.mentor.id === user.id), если токен старый или без metadata useEffect(() => { if (!effectiveLessonId || !user) return; const userId = user.id ?? (user as { pk?: number }).pk; if (!userId) return; getLesson(String(effectiveLessonId)) .then((lesson) => { const mentorId = typeof lesson.mentor === 'object' && lesson.mentor ? Number(lesson.mentor.id) : Number(lesson.mentor); if (mentorId && Number(userId) === mentorId) { setIsMentor(true); } }) .catch(() => {}); }, [effectiveLessonId, user]); useEffect(() => { const token = localStorage.getItem('access_token'); if (token) { const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; fetch(`${base}/profile/me/`, { headers: { Authorization: `Bearer ${token}` } }) .then((r) => r.json()) .then((u: { first_name?: string; last_name?: string; email?: string }) => { const raw = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || 'Пользователь'; const name = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw; setUserDisplayName(name); }) .catch(() => {}); } }, []); // Подсказка по видеозвонку для студента (один раз, после входа в комнату) useEffect(() => { if (user?.role !== 'client' || !onboarding || showPreJoin) return; const t = setTimeout(() => { onboarding.runTourManually('livekit'); }, 3500); return () => clearTimeout(t); }, [user?.role, onboarding, showPreJoin]); useEffect(() => { const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null; if (id && !isNaN(id)) { setEffectiveLessonId(id); try { sessionStorage.setItem('livekit_lesson_id', String(id)); } catch {} } else { try { const stored = sessionStorage.getItem('livekit_lesson_id'); if (stored) setEffectiveLessonId(parseInt(stored, 10)); } catch {} } }, [lessonIdParam]); useEffect(() => { const load = async () => { if (lessonIdParam) { try { const l = await getLesson(lessonIdParam); if (l.status === 'completed') { const now = new Date(); const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time); if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true); } } catch {} } const token = localStorage.getItem('access_token'); if (token) { try { const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api'; const res = await fetch(`${base}/video/livekit/config/`, { headers: { Authorization: `Bearer ${token}` } }); if (res.ok) { const config = await res.json(); setServerUrl(config.server_url || 'ws://127.0.0.1:7880'); } } catch {} } }; load(); }, [lessonIdParam]); if (lessonCompleted) { return (

Урок завершён. Видеоконференция недоступна.

); } if (!accessToken || !serverUrl) { return (

Загрузка...

); } if (showPreJoin) { return ( { try { sessionStorage.setItem(SS_PREJOIN_DONE, '1'); } catch {} setAudioEnabled(audio); setVideoEnabled(video); setShowPreJoin(false); }} onCancel={() => router.push('/dashboard')} /> ); } if (!avReady) { return (

Загрузка...

); } console.log('[LiveKit аудио/видео] Передача в LiveKitRoom:', { audio: audioEnabled, video: videoEnabled }); return ( <> {/* Доска — сиблинг LiveKitRoom, iframe создаётся один раз, не перемонтируется */} {boardId && (
)} router.push('/dashboard')} style={{ height: '100vh' }} data-lk-theme="default" options={{ adaptiveStream: true, dynacast: true, // Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше videoCaptureDefaults: { resolution: PRESET_2K.resolution, frameRate: 30, }, publishDefaults: { simulcast: true, // Камера: до 2K, 6 Mbps — вариативность через слои 1080p, 720p, 360p videoEncoding: PRESET_2K.encoding, // Два слоя поверх основного: 720p и 360p для вариативности при слабом канале videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360], // Демонстрация экрана: 2K, 6 Mbps, те же два слоя для адаптации screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 }, screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360], degradationPreference: 'maintain-resolution', }, audioCaptureDefaults: { noiseSuppression: true, echoCancellation: true, }, }} > ); }