967 lines
39 KiB
JavaScript
967 lines
39 KiB
JavaScript
'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>
|
||
</>
|
||
);
|
||
}
|