uchill/front_minimal/src/sections/video-call/view/video-call-view.jsx

967 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 <div style={{ flex: 1, background: '#000', minHeight: 200 }} />;
// 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 (
<div style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(12px)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20 }}>
<div style={{ background: 'rgba(255,255,255,0.06)', border: '1px solid rgba(255,255,255,0.12)', borderRadius: 20, padding: '32px 40px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 20, maxWidth: 360, textAlign: 'center' }}>
<div style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(25,118,210,0.2)', border: '1px solid rgba(25,118,210,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Iconify icon="solar:volume-loud-bold" width={28} style={{ color: '#60a5fa' }} />
</div>
<p style={{ color: '#fff', fontSize: 16, margin: 0, lineHeight: 1.5 }}>Чтобы слышать собеседника, разрешите воспроизведение звука</p>
<button type="button" onClick={handleClick} style={{ padding: '12px 28px', fontSize: 14, fontWeight: 600, borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer', letterSpacing: '0.02em' }}>
Разрешить звук
</button>
</div>
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ position: 'fixed', bottom: 96, right: chatOpen ? CHAT_PANEL_WIDTH + 16 : 16, width: 220, height: 124, zIndex: 10000, borderRadius: 12, overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.1)', background: '#000' }}>
<ParticipantTile trackRef={remoteRef} />
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', background: '#111' }}>
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
</div>
);
}
return (
<div
ref={iframeRef}
style={{ width: '100%', height: '100%', pointerEvents: showingBoard ? 'auto' : 'none' }}
/>
);
}
// ----------------------------------------------------------------------
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 (
<div style={S.page}>
<div style={S.card}>
<div style={S.header}>
<p style={S.title}>Настройки перед входом</p>
<p style={S.sub}>Проверьте камеру и микрофон</p>
</div>
<div style={S.body}>
<div style={S.preview}>
{videoEnabled
? <video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: (
<div style={S.previewOff}>
<Iconify icon="solar:camera-slash-bold" width={40} />
<span style={{ fontSize: 13 }}>Камера выключена</span>
</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{devices.map(({ key, label, icon, enabled, toggle }) => (
<div key={key} style={S.toggleRow}>
<span style={S.toggleLabel}>
<Iconify icon={icon} width={20} style={{ color: enabled ? '#60a5fa' : 'rgba(255,255,255,0.35)' }} />
{label}
</span>
<button type="button" onClick={toggle} style={S.toggleBtn(enabled)}>
{enabled ? 'Вкл' : 'Выкл'}
</button>
</div>
))}
</div>
<div style={S.actions}>
<button type="button" onClick={onCancel} style={S.cancelBtn}>Отмена</button>
<button type="button" onClick={handleJoin} style={S.joinBtn}>Войти в конференцию</button>
</div>
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------------
// 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 (
<div style={{ position: 'fixed', top: 0, right: 0, width: CHAT_PANEL_WIDTH, height: '100vh', background: '#0f0f0f', borderLeft: '1px solid rgba(255,255,255,0.08)', display: 'flex', flexDirection: 'column', zIndex: 10002, boxShadow: '-8px 0 40px rgba(0,0,0,0.5)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)', flexShrink: 0 }}>
<div style={{ width: 36, height: 36, borderRadius: '50%', background: 'linear-gradient(135deg, #1976d2, #7c4dff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Iconify icon="solar:chat-round-bold" width={18} style={{ color: '#fff' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontWeight: 600, fontSize: 14, lineHeight: 1.3 }}>Чат</div>
</div>
<button type="button" onClick={onClose} style={{ width: 32, height: 32, borderRadius: 8, border: 'none', background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<Iconify icon="solar:close-circle-bold" width={18} />
</button>
</div>
{loading && (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, color: 'rgba(255,255,255,0.35)' }}>
<Iconify icon="svg-spinners:ring-resize" width={28} style={{ color: '#1976d2' }} />
<span style={{ fontSize: 13 }}>Загрузка чата</span>
</div>
)}
{error && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, textAlign: 'center', color: '#fc8181', fontSize: 13 }}>
{error}
</div>
)}
{!loading && !error && (
<ChatWindow chat={chat} currentUserId={currentUser?.id ?? null} hideHeader />
)}
</div>
);
}
// ----------------------------------------------------------------------
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 }) => (
<button
type="button"
onClick={handleClick}
disabled={dis}
title={title}
style={{
width: 44,
height: 44,
borderRadius: 12,
border: active ? '1px solid rgba(25,118,210,0.5)' : '1px solid rgba(255,255,255,0.08)',
background: active ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.06)',
color: active ? '#60a5fa' : dis ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.7)',
cursor: dis ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.15s ease',
flexShrink: 0,
}}
>
<Iconify icon={icon} width={20} />
</button>
);
return (
<div style={{ height: '100vh', width: '100%', position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}>
<StartAudioOverlay />
<div style={{ position: 'absolute', inset: 0, zIndex: showBoard ? 0 : 1 }}>
<LiveKitLayoutErrorBoundary>
<VideoConference chatMessageFormatter={(message) => message} />
</LiveKitLayoutErrorBoundary>
</div>
{typeof document !== 'undefined' &&
createPortal(
<>
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
{/* Board burger button */}
{showBoard && (
<button
type="button"
onClick={() => setShowNavMenu((v) => !v)}
title="Меню"
style={{ position: 'fixed', left: 12, bottom: 96, width: 44, height: 44, borderRadius: 12, border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(15,15,15,0.85)', backdropFilter: 'blur(12px)', color: 'rgba(255,255,255,0.7)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }}
>
<Iconify icon="solar:hamburger-menu-bold" width={20} />
</button>
)}
{/* Nav menu — existing NavMobile, all links open in new tab */}
<NavMobile
open={showNavMenu}
onClose={() => 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 */}
<div style={{ position: 'fixed', right: sidebarRight, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', gap: 6, padding: 6, background: 'rgba(10,10,10,0.80)', backdropFilter: 'blur(16px)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, zIndex: 10001, transition: 'right 0.25s ease' }}>
<SideBtn active={!showBoard} disabled={false} title="Видеоконференция" icon="solar:videocamera-bold" onClick={() => setShowBoard(false)} />
<SideBtn
active={showBoard}
disabled={!boardId || boardLoading}
title={boardLoading ? 'Загрузка доски…' : !boardId ? 'Доска недоступна' : 'Доска'}
icon={boardLoading ? 'svg-spinners:ring-resize' : 'solar:pen-new-square-bold'}
onClick={() => boardId && !boardLoading && setShowBoard(true)}
/>
{lessonId != null && (
<SideBtn active={showPlatformChat} disabled={false} title="Чат" icon="solar:chat-round-bold" onClick={() => setShowPlatformChat((v) => !v)} />
)}
{/* Divider */}
<div style={{ height: 1, background: 'rgba(255,255,255,0.10)', margin: '2px 0' }} />
{/* Exit button */}
<button
ref={exitBtnRef}
type="button"
onClick={handleToggleExitMenu}
title="Выйти"
style={{ width: 44, height: 44, borderRadius: 12, border: `1px solid ${showExitMenu ? 'rgba(239,68,68,0.6)' : 'rgba(239,68,68,0.35)'}`, background: showExitMenu ? 'rgba(239,68,68,0.22)' : 'rgba(239,68,68,0.12)', color: '#f87171', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.15s ease', flexShrink: 0 }}
>
<Iconify icon={showExitMenu ? 'solar:close-bold' : 'solar:exit-bold'} width={20} />
</button>
</div>
{/* Exit menu popup — anchored to the exit button rect */}
{showExitMenu && exitBtnRect && (
<>
{/* Backdrop */}
<div
style={{ position: 'fixed', inset: 0, zIndex: 10010 }}
onClick={() => setShowExitMenu(false)}
/>
{/* Popup: right edge aligned with exit button right edge, bottom edge at exit button top - 8px */}
<div style={{
position: 'fixed',
right: window.innerWidth - exitBtnRect.right,
bottom: window.innerHeight - exitBtnRect.top + 8,
zIndex: 10011,
background: 'rgba(18,18,18,0.97)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 14,
padding: 8,
backdropFilter: 'blur(20px)',
display: 'flex',
flexDirection: 'column',
gap: 6,
minWidth: 240,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
}}>
<button
type="button"
onClick={handleJustExit}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', borderRadius: 10, border: 'none', background: 'rgba(255,255,255,0.06)', color: 'rgba(255,255,255,0.85)', fontSize: 14, cursor: 'pointer', textAlign: 'left', whiteSpace: 'nowrap' }}
>
<Iconify icon="solar:exit-bold" width={18} style={{ color: '#94a3b8', flexShrink: 0 }} />
Выйти
</button>
{isMentor && (
<button
type="button"
onClick={handleTerminateForAll}
disabled={terminatingAll}
style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', borderRadius: 10, border: 'none', background: 'rgba(239,68,68,0.12)', color: terminatingAll ? 'rgba(248,113,113,0.5)' : '#f87171', fontSize: 14, cursor: terminatingAll ? 'not-allowed' : 'pointer', textAlign: 'left', whiteSpace: 'nowrap' }}
>
<Iconify
icon={terminatingAll ? 'svg-spinners:ring-resize' : 'solar:close-circle-bold'}
width={18}
style={{ color: terminatingAll ? 'rgba(248,113,113,0.5)' : '#f87171', flexShrink: 0 }}
/>
{terminatingAll ? 'Завершение…' : 'Выйти и завершить для всех'}
</button>
)}
</div>
</>
)}
{showPlatformChat && lesson && (
<VideoCallChatPanel
lesson={lesson}
currentUser={user}
onClose={() => setShowPlatformChat(false)}
/>
)}
</>,
document.body
)}
</div>
);
}
// ----------------------------------------------------------------------
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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: '#f5f5f5' }}>
<div style={{ textAlign: 'center', padding: 24 }}>
<p style={{ fontSize: 18, marginBottom: 16 }}>Урок завершён. Видеоконференция недоступна.</p>
<button type="button" onClick={() => router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: '#1976d2', color: '#fff', cursor: 'pointer' }}>
На главную
</button>
</div>
</div>
);
}
if (!accessToken || !serverUrl) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<p>Загрузка...</p>
</div>
);
}
if (showPreJoin) {
return (
<PreJoinScreen
onJoin={(audio, video) => {
try {
sessionStorage.setItem(SS_PREJOIN_DONE, '1');
} catch { /* ignore */ }
setAudioEnabled(audio);
setVideoEnabled(video);
setShowPreJoin(false);
}}
onCancel={() => router.push('/dashboard')}
/>
);
}
if (!avReady) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<p>Загрузка...</p>
</div>
);
}
return (
<>
{boardId && (
<div
key="board-layer"
style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }}
>
<WhiteboardIframe boardId={boardId} showingBoard={showBoard} user={user} />
</div>
)}
<LiveKitRoom
token={accessToken}
serverUrl={serverUrl}
connect
audio={audioEnabled}
video={videoEnabled}
onDisconnected={() => 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 },
}}
>
<RoomContent
lessonId={effectiveLessonId}
lesson={lesson}
boardId={boardId}
boardLoading={boardLoading}
showBoard={showBoard}
setShowBoard={setShowBoard}
postDisconnectRef={postDisconnectRef}
/>
<RoomAudioRenderer />
<ConnectionStateToast />
</LiveKitRoom>
</>
);
}