'use client'; import 'src/styles/livekit-theme.css'; import 'src/styles/livekit-components.css'; import { createPortal } from 'react-dom'; import { isTrackReference } from '@livekit/components-core'; import { useRouter, useSearchParams } from 'src/routes/hooks'; import { useRef, useState, useEffect, Component } from 'react'; import { Track, RoomEvent, VideoPresets } from 'livekit-client'; import { useTracks, LiveKitRoom, useStartAudio, useRoomContext, VideoConference, ParticipantTile, RoomAudioRenderer, ConnectionStateToast, } from '@livekit/components-react'; import { paths } from 'src/routes/paths'; import { getLesson, completeLesson } from 'src/utils/dashboard-api'; import { buildExcalidrawSrc, getOrCreateLessonBoard } from 'src/utils/board-api'; import { getLiveKitConfig, participantConnected, terminateRoom } from 'src/utils/livekit-api'; import { createChat, normalizeChat } from 'src/utils/chat-api'; // Payload sent via LiveKit DataChannel to force-disconnect all participants const TERMINATE_MSG = JSON.stringify({ type: 'room_terminate' }); import { Iconify } from 'src/components/iconify'; import { ChatWindow } from 'src/sections/chat/chat-window'; import { NavMobile } from 'src/layouts/dashboard/nav-mobile'; import { getNavData } from 'src/layouts/config-nav-dashboard'; import { useAuthContext } from 'src/auth/hooks'; // ---------------------------------------------------------------------- const PRESET_2K = { resolution: { width: 2560, height: 1440 }, encoding: { maxBitrate: 6_000_000, maxFramerate: 30 }, }; const CHAT_PANEL_WIDTH = 420; const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed'; const LS_AUDIO_ENABLED = 'videoConference_audioEnabled'; const LS_VIDEO_ENABLED = 'videoConference_videoEnabled'; const SS_PREJOIN_DONE = 'livekit_prejoin_done'; // ---------------------------------------------------------------------- function isLiveKitLayoutError(error) { 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 Component { constructor(props) { super(props); this.state = { error: null, recoverKey: 0 }; } static getDerivedStateFromError(error) { return { error }; } componentDidCatch(error) { if (isLiveKitLayoutError(error)) { window.setTimeout(() => { this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 })); }, 100); } } render() { const { error, recoverKey } = this.state; const { children } = this.props; if (error && !isLiveKitLayoutError(error)) throw error; if (error) return
; // eslint-disable-next-line react/jsx-no-useless-fragment return <>{children}; } } // ---------------------------------------------------------------------- function getSavedAudioVideo() { try { const rawA = localStorage.getItem(LS_AUDIO_ENABLED); const rawV = localStorage.getItem(LS_VIDEO_ENABLED); return { audio: rawA !== 'false', video: rawV !== 'false' }; } catch { return { audio: true, video: true }; } } function getInitialShowPreJoin(sp) { try { if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') return false; return sp.get('skip_prejoin') !== '1'; } catch { return true; } } function getTokenMetadata(token) { if (!token) return {}; try { const parts = token.split('.'); if (parts.length !== 3) return {}; const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))); const { metadata } = payload; if (!metadata || typeof metadata !== 'string') return {}; const parsed = JSON.parse(metadata); return { board_id: parsed?.board_id ?? undefined, is_mentor: parsed?.is_mentor === true, }; } catch { return {}; } } // ---------------------------------------------------------------------- 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 { /* ignore */ } } }, [canPlayAudio]); if (canPlayAudio || dismissed) return null; const handleClick = () => { try { localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true'); } catch { /* ignore */ } setDismissed(true); mergedProps.onClick?.(); }; return (

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

); } // ---------------------------------------------------------------------- function RemoteParticipantPiP({ chatOpen }) { 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 (
); } // ---------------------------------------------------------------------- function WhiteboardIframe({ boardId, showingBoard, user }) { const iframeRef = useRef(null); const excalidrawConfigured = !!( process.env.NEXT_PUBLIC_EXCALIDRAW_URL || process.env.NEXT_PUBLIC_EXCALIDRAW_PATH ); useEffect(() => { if (!excalidrawConfigured || !boardId) return undefined; const container = iframeRef.current; if (!container) return undefined; const iframe = document.createElement('iframe'); iframe.src = buildExcalidrawSrc(boardId, user); iframe.style.cssText = 'width:100%;height:100%;border:none;'; iframe.allow = 'camera; microphone; fullscreen'; container.innerHTML = ''; container.appendChild(iframe); return () => { container.innerHTML = ''; }; }, [boardId, excalidrawConfigured, user]); if (!excalidrawConfigured) { return (
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
); } return (
); } // ---------------------------------------------------------------------- function PreJoinScreen({ onJoin, onCancel }) { const [audioEnabled, setAudioEnabled] = useState(true); const [videoEnabled, setVideoEnabled] = useState(true); const videoRef = useRef(null); useEffect(() => { const saved = getSavedAudioVideo(); setAudioEnabled(saved.audio); setVideoEnabled(saved.video); }, []); useEffect(() => { if (!videoEnabled) return undefined; let stream = null; navigator.mediaDevices .getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 } }, audio: false }) .then((s) => { stream = s; if (videoRef.current) videoRef.current.srcObject = s; }) .catch(() => {}); return () => { stream?.getTracks().forEach((t) => t.stop()); }; }, [videoEnabled]); const handleJoin = () => { try { localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled)); localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled)); } catch { /* ignore */ } onJoin(audioEnabled, videoEnabled); }; const S = { page: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', overflow: 'hidden' }, card: { background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.10)', borderRadius: 22, width: '100%', maxWidth: 480, overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.6)' }, header: { padding: '24px 28px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' }, title: { fontSize: 20, fontWeight: 700, color: '#fff', margin: 0 }, sub: { fontSize: 13, color: 'rgba(255,255,255,0.5)', margin: '4px 0 0' }, body: { padding: 24, display: 'flex', flexDirection: 'column', gap: 16 }, preview: { background: '#111', borderRadius: 14, aspectRatio: '16/9', overflow: 'hidden', position: 'relative' }, previewOff: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, color: 'rgba(255,255,255,0.35)' }, toggleRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 12 }, toggleLabel: { display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.85)', fontSize: 14 }, toggleBtn: (on) => ({ padding: '7px 18px', borderRadius: 9, border: 'none', background: on ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.08)', color: on ? '#60a5fa' : 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 600, cursor: 'pointer', transition: 'all 0.15s' }), actions: { display: 'flex', gap: 10 }, cancelBtn: { flex: 1, padding: '13px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: 'rgba(255,255,255,0.6)', fontSize: 14, cursor: 'pointer' }, joinBtn: { flex: 2, padding: '13px', borderRadius: 12, border: 'none', background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)', color: '#fff', fontSize: 14, fontWeight: 700, cursor: 'pointer', letterSpacing: '0.02em', boxShadow: '0 4px 16px rgba(25,118,210,0.35)' }, }; const devices = [ { key: 'mic', label: 'Микрофон', icon: audioEnabled ? 'solar:microphone-bold' : 'solar:microphone-slash-bold', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) }, { key: 'cam', label: 'Камера', icon: videoEnabled ? 'solar:camera-bold' : 'solar:camera-slash-bold', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) }, ]; return (

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

Проверьте камеру и микрофон

{videoEnabled ?
{devices.map(({ key, label, icon, enabled, toggle }) => (
{label}
))}
); } // ---------------------------------------------------------------------- // Panel width constant already defined at top // ---------------------------------------------------------------------- function VideoCallChatPanel({ lesson, currentUser, onClose }) { const [chat, setChat] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!lesson || !currentUser) return; const getOtherUserId = () => { if (currentUser.role === 'mentor') { const client = lesson.client; if (!client) return null; if (typeof client === 'object') return client.user?.id ?? client.id ?? null; return client; } const mentor = lesson.mentor; if (!mentor) return null; if (typeof mentor === 'object') return mentor.id ?? null; return mentor; }; const otherId = getOtherUserId(); if (!otherId) { setError('Не удалось определить собеседника'); setLoading(false); return; } setLoading(true); createChat(otherId) .then((raw) => { const enriched = { ...raw }; if (!enriched.other_participant) { // fallback: determine name from lesson data const other = currentUser.role === 'mentor' ? lesson.client : lesson.mentor; if (other && typeof other === 'object') { const u = other.user || other; enriched.other_participant = { id: otherId, first_name: u.first_name, last_name: u.last_name, avatar_url: u.avatar_url || u.avatar || null, }; } } setChat(normalizeChat(enriched)); }) .catch((e) => setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки чата')) .finally(() => setLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [lesson?.id, currentUser?.id]); return (
{/* Header */}
Чат
{loading && (
Загрузка чата…
)} {error && (
{error}
)} {!loading && !error && ( )}
); } // ---------------------------------------------------------------------- function RoomContent({ lessonId, lesson, boardId, boardLoading, showBoard, setShowBoard, postDisconnectRef }) { const room = useRoomContext(); const router = useRouter(); const { user } = useAuthContext(); const [showPlatformChat, setShowPlatformChat] = useState(false); const [showExitMenu, setShowExitMenu] = useState(false); const [showNavMenu, setShowNavMenu] = useState(false); const [terminatingAll, setTerminatingAll] = useState(false); const [exitBtnRect, setExitBtnRect] = useState(null); const exitBtnRef = useRef(null); const isMentor = user?.role === 'mentor'; const handleToggleExitMenu = () => { if (!showExitMenu && exitBtnRef.current) { setExitBtnRect(exitBtnRef.current.getBoundingClientRect()); } setShowExitMenu((v) => !v); }; 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, lessonId]); // Inject exit and burger buttons into LiveKit control bar useEffect(() => { if (showBoard) return undefined; 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.textContent = '☰'; 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.title = 'Выйти'; btn.textContent = '🚪'; btn.addEventListener('click', (ev) => window.dispatchEvent(new CustomEvent('livekit-exit-click', { detail: { target: ev.currentTarget } }))); 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(() => { // Control bar button click: capture its rect for popup positioning const handler = (e) => { const btn = e?.detail?.target ?? document.querySelector('.lk-custom-exit-button'); if (btn) setExitBtnRect(btn.getBoundingClientRect()); setShowExitMenu(true); }; window.addEventListener('livekit-exit-click', handler); return () => window.removeEventListener('livekit-exit-click', handler); }, []); const handleJustExit = () => { setShowExitMenu(false); room.disconnect(); }; const handleTerminateForAll = async () => { setShowExitMenu(false); setTerminatingAll(true); if (lessonId != null) { try { sessionStorage.setItem('complete_lesson_id', String(lessonId)); } catch { /* ignore */ } if (postDisconnectRef) postDisconnectRef.current = paths.dashboard.calendar; // 1. Broadcast terminate signal via LiveKit DataChannel so clients // disconnect immediately (fallback if backend call is slow/fails). try { await room.localParticipant.publishData( new TextEncoder().encode(TERMINATE_MSG), { reliable: true } ); } catch { /* ignore */ } // 2. Backend: terminate LiveKit room + mark lesson as completed. try { await terminateRoom(lessonId); } catch { /* ignore */ } // 3. Ensure lesson status is "completed" on backend even if // terminateRoom doesn't handle it. try { await completeLesson(String(lessonId), '', undefined, undefined, undefined, false, undefined); } catch { /* ignore */ } } room.disconnect(); }; // Listen for terminate broadcast from mentor (runs on client side). useEffect(() => { if (isMentor) return undefined; const onData = (payload) => { try { const msg = JSON.parse(new TextDecoder().decode(payload)); if (msg?.type === 'room_terminate') { room.disconnect(); } } catch { /* ignore */ } }; room.on(RoomEvent.DataReceived, onData); return () => { room.off(RoomEvent.DataReceived, onData); }; }, [room, isMentor]); // Save audio/video state on track events useEffect(() => { const lp = room.localParticipant; if (!lp) return undefined; const save = () => { try { localStorage.setItem(LS_AUDIO_ENABLED, String(lp.isMicrophoneEnabled)); localStorage.setItem(LS_VIDEO_ENABLED, String(lp.isCameraEnabled)); } catch { /* ignore */ } }; const onMuted = (pub, participant) => { if (participant?.sid !== lp.sid) return; if (pub?.source === Track.Source.Microphone) { try { localStorage.setItem(LS_AUDIO_ENABLED, 'false'); } catch { /* ignore */ } } else if (pub?.source === Track.Source.Camera) { try { localStorage.setItem(LS_VIDEO_ENABLED, 'false'); } catch { /* ignore */ } } else { save(); } }; const onUnmuted = (pub, participant) => { if (participant?.sid !== lp.sid) return; if (pub?.source === Track.Source.Microphone) { try { localStorage.setItem(LS_AUDIO_ENABLED, 'true'); } catch { /* ignore */ } } else if (pub?.source === Track.Source.Camera) { try { localStorage.setItem(LS_VIDEO_ENABLED, 'true'); } catch { /* ignore */ } } else { save(); } }; room.on(RoomEvent.TrackMuted, onMuted); room.on(RoomEvent.TrackUnmuted, onUnmuted); room.on(RoomEvent.LocalTrackPublished, save); save(); return () => { room.off(RoomEvent.TrackMuted, onMuted); room.off(RoomEvent.TrackUnmuted, onUnmuted); room.off(RoomEvent.LocalTrackPublished, save); }; }, [room]); const sidebarRight = showPlatformChat ? CHAT_PANEL_WIDTH + 12 : 12; const SideBtn = ({ active, disabled: dis, title, icon, onClick: handleClick }) => ( ); return (
message} />
{typeof document !== 'undefined' && createPortal( <> {showBoard && } {/* Board burger button */} {showBoard && ( )} {/* Nav menu — existing NavMobile, all links open in new tab */} setShowNavMenu(false)} data={getNavData(user?.role).map((section) => ({ ...section, items: section.items.map((item) => ({ ...item, externalLink: true })), }))} sx={{ bgcolor: 'grey.900', width: 300 }} /> {/* Right sidebar */}
setShowBoard(false)} /> boardId && !boardLoading && setShowBoard(true)} /> {lessonId != null && ( setShowPlatformChat((v) => !v)} /> )} {/* Divider */}
{/* Exit button */}
{/* Exit menu popup — anchored to the exit button rect */} {showExitMenu && exitBtnRect && ( <> {/* Backdrop */}
setShowExitMenu(false)} /> {/* Popup: right edge aligned with exit button right edge, bottom edge at exit button top - 8px */}
{isMentor && ( )}
)} {showPlatformChat && lesson && ( setShowPlatformChat(false)} /> )} , document.body )}
); } // ---------------------------------------------------------------------- export function VideoCallView() { const router = useRouter(); const searchParams = useSearchParams(); const accessToken = searchParams.get('token'); const lessonIdParam = searchParams.get('lesson_id'); const { user } = useAuthContext(); 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); const [lessonCompleted, setLessonCompleted] = useState(false); const [effectiveLessonId, setEffectiveLessonId] = useState(null); const [lesson, setLesson] = useState(null); const [boardId, setBoardId] = useState(null); const [boardLoading, setBoardLoading] = useState(false); const [showBoard, setShowBoard] = useState(false); const boardPollRef = useRef(null); const postDisconnectRef = useRef('/dashboard'); // Lock scroll while on the video-call page, restore on unmount useEffect(() => { const prev = document.documentElement.style.overflow; document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; return () => { document.documentElement.style.overflow = prev; document.body.style.overflow = ''; }; }, []); // Load audio/video preferences from localStorage after mount useEffect(() => { const saved = getSavedAudioVideo(); setAudioEnabled(saved.audio); setVideoEnabled(saved.video); setAvReady(true); }, []); // Determine effective lesson ID useEffect(() => { const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null; if (id && !Number.isNaN(id)) { setEffectiveLessonId(id); try { sessionStorage.setItem('livekit_lesson_id', String(id)); } catch { /* ignore */ } } else { try { const stored = sessionStorage.getItem('livekit_lesson_id'); if (stored) setEffectiveLessonId(parseInt(stored, 10)); } catch { /* ignore */ } } }, [lessonIdParam]); // Load server URL + check lesson status useEffect(() => { const load = async () => { if (lessonIdParam) { try { const l = await getLesson(lessonIdParam); setLesson(l); 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 { /* ignore */ } } try { const config = await getLiveKitConfig(); setServerUrl(config.server_url || 'ws://127.0.0.1:7880'); } catch { /* ignore */ } }; load(); }, [lessonIdParam]); // Board: from token metadata or poll API useEffect(() => { const meta = getTokenMetadata(accessToken); if (meta.board_id) { setBoardId(meta.board_id); setBoardLoading(false); return undefined; } if (!effectiveLessonId) { setBoardLoading(false); return undefined; } 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]); if (lessonCompleted) { return (

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

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

Загрузка...

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

Загрузка...

); } return ( <> {boardId && (
)} router.push(postDisconnectRef.current)} style={{ height: '100vh' }} data-lk-theme="default" options={{ adaptiveStream: true, dynacast: true, videoCaptureDefaults: { resolution: PRESET_2K.resolution, frameRate: 30 }, publishDefaults: { simulcast: true, videoEncoding: PRESET_2K.encoding, videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360], screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 }, screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360], degradationPreference: 'maintain-resolution', }, audioCaptureDefaults: { noiseSuppression: true, echoCancellation: true }, }} > ); }