'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 },
}}
>
>
);
}