1078 lines
41 KiB
TypeScript
1078 lines
41 KiB
TypeScript
'use client';
|
||
|
||
/**
|
||
* LiveKit видеокомната — вариант из коробки (@livekit/components-react)
|
||
*/
|
||
|
||
import React, { Suspense, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
/** Ошибка LiveKit updatePages (placeholder/track race) — не выкидываем пользователя из занятия. */
|
||
function isLiveKitLayoutError(error: unknown): boolean {
|
||
const msg = error instanceof Error ? error.message : String(error);
|
||
return (
|
||
msg.includes('Element not part of the array') ||
|
||
msg.includes('updatePages') ||
|
||
msg.includes('_placeholder not in')
|
||
);
|
||
}
|
||
|
||
class LiveKitLayoutErrorBoundary extends React.Component<
|
||
{ children: React.ReactNode },
|
||
{ error: unknown; recoverKey: number }
|
||
> {
|
||
state = { error: null as unknown, recoverKey: 0 };
|
||
|
||
static getDerivedStateFromError(error: unknown) {
|
||
return { error };
|
||
}
|
||
|
||
componentDidCatch(error: unknown) {
|
||
if (isLiveKitLayoutError(error)) {
|
||
window.setTimeout(() => {
|
||
this.setState((s) => ({ error: null, recoverKey: s.recoverKey + 1 }));
|
||
}, 100);
|
||
}
|
||
}
|
||
|
||
render() {
|
||
if (this.state.error && !isLiveKitLayoutError(this.state.error)) {
|
||
throw this.state.error;
|
||
}
|
||
if (this.state.error) {
|
||
return (
|
||
<div style={{ flex: 1, background: '#000', minHeight: 200 }} />
|
||
);
|
||
}
|
||
return <React.Fragment key={this.state.recoverKey}>{this.props.children}</React.Fragment>;
|
||
}
|
||
}
|
||
import { useRouter, useSearchParams } from 'next/navigation';
|
||
import { LiveKitRoom, VideoConference, RoomAudioRenderer, ConnectionStateToast, useTracks, useRemoteParticipants, ParticipantTile, useRoomContext, useStartAudio } from '@livekit/components-react';
|
||
import { ExitLessonModal } from '@/components/livekit/ExitLessonModal';
|
||
import { Track, RoomEvent, VideoPresets } from 'livekit-client';
|
||
|
||
/** 2K (1440p) — разрешение и кодирование для высокого качества при хорошем канале */
|
||
const PRESET_2K = {
|
||
resolution: { width: 2560, height: 1440 },
|
||
encoding: { maxBitrate: 6_000_000, maxFramerate: 30 } as const,
|
||
};
|
||
import { isTrackReference } from '@livekit/components-core';
|
||
import '@/styles/livekit-components.css';
|
||
import '@/styles/livekit-theme.css';
|
||
import { getLesson } from '@/api/schedule';
|
||
import { participantConnected } from '@/api/livekit';
|
||
import type { Lesson } from '@/api/schedule';
|
||
import { getOrCreateLessonBoard } from '@/api/board';
|
||
|
||
/** Извлечь board_id и is_mentor из metadata LiveKit JWT (без верификации). */
|
||
function getTokenMetadata(token: string | null): { board_id?: string; is_mentor?: boolean } {
|
||
if (!token) return {};
|
||
try {
|
||
const parts = token.split('.');
|
||
if (parts.length !== 3) return {};
|
||
const payload = JSON.parse(
|
||
atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))
|
||
) as { metadata?: string };
|
||
const meta = payload.metadata;
|
||
if (!meta || typeof meta !== 'string') return {};
|
||
const parsed = JSON.parse(meta) as { board_id?: string; is_mentor?: boolean };
|
||
return {
|
||
board_id: parsed?.board_id ?? undefined,
|
||
is_mentor: parsed?.is_mentor === true,
|
||
};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
import { WhiteboardIframe } from '@/components/board/WhiteboardIframe';
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||
import { getOrCreateLessonChat } from '@/api/chat';
|
||
import type { Chat } from '@/api/chat';
|
||
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { getAvatarUrl } from '@/api/profile';
|
||
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
|
||
import { getNavBadges } from '@/api/navBadges';
|
||
import type { NavBadges } from '@/api/navBadges';
|
||
|
||
const CHAT_PANEL_WIDTH = 420;
|
||
|
||
/** Камера собеседника в углу при открытой доске; сдвигается влево, когда открыт чат */
|
||
function RemoteParticipantPiP({ chatOpen }: { chatOpen: boolean }) {
|
||
const tracks = useTracks(
|
||
[Track.Source.Camera, Track.Source.ScreenShare],
|
||
{ onlySubscribed: true }
|
||
);
|
||
const remoteRef = tracks.find(
|
||
(ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal
|
||
);
|
||
if (!remoteRef || !isTrackReference(remoteRef)) return null;
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
bottom: 24,
|
||
right: chatOpen ? CHAT_PANEL_WIDTH + 24 : 24,
|
||
width: 280,
|
||
height: 158,
|
||
zIndex: 10000,
|
||
borderRadius: 12,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
|
||
border: '2px solid rgba(255,255,255,0.2)',
|
||
background: '#000',
|
||
}}
|
||
>
|
||
<ParticipantTile trackRef={remoteRef} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const AVATAR_IMG_CLASS = 'lk-participant-avatar-img';
|
||
|
||
/**
|
||
* Подставляет аватары в плейсхолдер, когда камера выключена:
|
||
* — у удалённых участников (собеседник видит их фото);
|
||
* — у локального участника (своё фото вижу я и собеседник).
|
||
* GET /api/users/<identity>/avatar_url/
|
||
*/
|
||
function RemoteParticipantAvatarPlaceholder() {
|
||
const { user } = useAuth();
|
||
const remoteParticipants = useRemoteParticipants();
|
||
const [avatarUrls, setAvatarUrls] = useState<Map<string, string>>(new Map());
|
||
const [localAvatarUrl, setLocalAvatarUrl] = useState<string | null>(null);
|
||
const injectedRef = useRef<WeakSet<Element>>(new WeakSet());
|
||
const identityKey = remoteParticipants.map((p) => p.identity).sort().join(',');
|
||
|
||
// Аватар собеседников
|
||
useEffect(() => {
|
||
if (remoteParticipants.length === 0) {
|
||
setAvatarUrls(new Map());
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
const map = new Map<string, string>();
|
||
Promise.all(
|
||
remoteParticipants.map(async (p) => {
|
||
const id = p.identity;
|
||
const url = await getAvatarUrl(id);
|
||
return { id, url } as const;
|
||
})
|
||
).then((results) => {
|
||
if (cancelled) return;
|
||
results.forEach(({ id, url }) => {
|
||
if (url) map.set(id, url);
|
||
});
|
||
setAvatarUrls(new Map(map));
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [identityKey]);
|
||
|
||
// Свой аватар (чтобы видел и я, и собеседник, когда камера выключена)
|
||
useEffect(() => {
|
||
const id = user?.id;
|
||
if (id == null) {
|
||
setLocalAvatarUrl(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
getAvatarUrl(String(id)).then((url) => {
|
||
if (!cancelled) setLocalAvatarUrl(url);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [user?.id]);
|
||
|
||
const runInject = React.useCallback(() => {
|
||
const injectInto = (placeholder: Element, url: string | null) => {
|
||
let img = placeholder.querySelector(`img.${AVATAR_IMG_CLASS}`) as HTMLImageElement | null;
|
||
if (url) {
|
||
if (!img) {
|
||
img = document.createElement('img');
|
||
img.className = AVATAR_IMG_CLASS;
|
||
img.alt = '';
|
||
placeholder.appendChild(img);
|
||
}
|
||
if (img.src !== url) img.src = url;
|
||
injectedRef.current.add(placeholder);
|
||
} else {
|
||
if (img) img.remove();
|
||
}
|
||
};
|
||
|
||
// Плитки собеседников
|
||
const remotePlaceholders = document.querySelectorAll(
|
||
'.lk-participant-tile[data-lk-local-participant="false"] .lk-participant-placeholder'
|
||
);
|
||
const urls = remoteParticipants.map((p) => avatarUrls.get(p.identity) ?? null);
|
||
remotePlaceholders.forEach((placeholder, i) => {
|
||
const url = urls[Math.min(i, urls.length - 1)] ?? null;
|
||
injectInto(placeholder, url);
|
||
});
|
||
|
||
// Своя плитка — свой аватар (вижу я и собеседник)
|
||
const localPlaceholders = document.querySelectorAll(
|
||
'.lk-participant-tile[data-lk-local-participant="true"] .lk-participant-placeholder'
|
||
);
|
||
localPlaceholders.forEach((placeholder) => injectInto(placeholder, localAvatarUrl));
|
||
}, [remoteParticipants, avatarUrls, localAvatarUrl]);
|
||
|
||
useLayoutEffect(() => {
|
||
runInject();
|
||
const t1 = setTimeout(runInject, 300);
|
||
const t2 = setTimeout(runInject, 1000);
|
||
return () => {
|
||
clearTimeout(t1);
|
||
clearTimeout(t2);
|
||
};
|
||
}, [runInject]);
|
||
|
||
return null;
|
||
}
|
||
|
||
const LS_AUDIO_PLAYBACK_ALLOWED = 'videoConference_audioPlaybackAllowed';
|
||
const LS_AUDIO_ENABLED = 'videoConference_audioEnabled';
|
||
const LS_VIDEO_ENABLED = 'videoConference_videoEnabled';
|
||
|
||
/**
|
||
* Оверлей «Разрешить звук» — показываем только при первом посещении.
|
||
* После клика сохраняем в localStorage, чтобы не спрашивать постоянно.
|
||
*/
|
||
function StartAudioOverlay() {
|
||
const room = useRoomContext();
|
||
const { mergedProps, canPlayAudio } = useStartAudio({ room, props: {} });
|
||
const [dismissed, setDismissed] = useState(() => {
|
||
try {
|
||
return localStorage.getItem(LS_AUDIO_PLAYBACK_ALLOWED) === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
useEffect(() => {
|
||
if (canPlayAudio) {
|
||
try {
|
||
localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
|
||
} catch {}
|
||
}
|
||
}, [canPlayAudio]);
|
||
if (canPlayAudio || dismissed) return null;
|
||
const handleClick = () => {
|
||
try {
|
||
localStorage.setItem(LS_AUDIO_PLAYBACK_ALLOWED, 'true');
|
||
} catch {}
|
||
setDismissed(true);
|
||
mergedProps.onClick?.();
|
||
};
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 9999,
|
||
background: 'rgba(0,0,0,0.75)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
gap: 24,
|
||
padding: 24,
|
||
}}
|
||
>
|
||
<p style={{ color: '#fff', fontSize: 18, textAlign: 'center', margin: 0 }}>
|
||
Чтобы слышать собеседника, разрешите воспроизведение звука
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={handleClick}
|
||
style={{
|
||
padding: '16px 32px',
|
||
fontSize: 18,
|
||
fontWeight: 600,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: 'var(--md-sys-color-primary)',
|
||
color: 'var(--md-sys-color-on-primary)',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>volume_up</span>
|
||
Разрешить звук
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PreJoinScreen({
|
||
onJoin,
|
||
onCancel,
|
||
}: {
|
||
onJoin: (audio: boolean, video: boolean) => void;
|
||
onCancel: () => void;
|
||
}) {
|
||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||
const [videoEnabled, setVideoEnabled] = useState(true);
|
||
const [preview, setPreview] = useState<MediaStream | null>(null);
|
||
const videoRef = React.useRef<HTMLVideoElement>(null);
|
||
|
||
// Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
|
||
useEffect(() => {
|
||
const saved = getSavedAudioVideo();
|
||
setAudioEnabled(saved.audio);
|
||
setVideoEnabled(saved.video);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!videoEnabled) return;
|
||
let stream: MediaStream | null = null;
|
||
navigator.mediaDevices
|
||
.getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
|
||
.then((s) => {
|
||
stream = s;
|
||
setPreview(s);
|
||
if (videoRef.current) videoRef.current.srcObject = s;
|
||
})
|
||
.catch(() => {});
|
||
return () => {
|
||
stream?.getTracks().forEach((t) => t.stop());
|
||
setPreview(null);
|
||
};
|
||
}, [videoEnabled]);
|
||
|
||
const handleJoin = () => {
|
||
try {
|
||
localStorage.setItem(LS_AUDIO_ENABLED, String(audioEnabled));
|
||
localStorage.setItem(LS_VIDEO_ENABLED, String(videoEnabled));
|
||
console.log(`[LiveKit аудио/видео] PreJoin handleJoin: audio=${audioEnabled}, video=${videoEnabled} → localStorage`);
|
||
} catch {}
|
||
preview?.getTracks().forEach((t) => t.stop());
|
||
onJoin(audioEnabled, videoEnabled);
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%)' }}>
|
||
<div style={{ background: 'var(--md-sys-color-surface)', borderRadius: 20, maxWidth: 520, width: '100%', overflow: 'hidden', boxShadow: '0 8px 32px rgba(0,0,0,0.1)' }}>
|
||
<div style={{ background: 'linear-gradient(135deg, var(--md-sys-color-primary) 0%, #7c4dff 100%)', padding: 24, color: '#fff' }}>
|
||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>Настройки перед входом</h1>
|
||
<p style={{ margin: '8px 0 0 0', opacity: 0.9 }}>Настройте камеру и микрофон</p>
|
||
</div>
|
||
<div style={{ padding: 24 }}>
|
||
<div style={{ marginBottom: 24, background: '#000', borderRadius: 12, aspectRatio: '16/9', overflow: 'hidden' }}>
|
||
{videoEnabled ? (
|
||
<video ref={videoRef} autoPlay playsInline muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||
) : (
|
||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', color: '#666' }}>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 64 }}>videocam_off</span>
|
||
<p style={{ margin: 8 }}>Камера выключена</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: 'var(--md-sys-color-surface-variant)', borderRadius: 12 }}>
|
||
<span>Микрофон</span>
|
||
<button
|
||
onClick={() => {
|
||
setAudioEnabled((v) => {
|
||
const next = !v;
|
||
try {
|
||
localStorage.setItem(LS_AUDIO_ENABLED, String(next));
|
||
console.log(`[LiveKit аудио/видео] PreJoin toggle микрофон: ${next} → localStorage`);
|
||
} catch {}
|
||
return next;
|
||
});
|
||
}}
|
||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: audioEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
|
||
>
|
||
{audioEnabled ? 'Выключить' : 'Включить'}
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 16, background: 'var(--md-sys-color-surface-variant)', borderRadius: 12 }}>
|
||
<span>Камера</span>
|
||
<button
|
||
onClick={() => {
|
||
setVideoEnabled((v) => {
|
||
const next = !v;
|
||
try {
|
||
localStorage.setItem(LS_VIDEO_ENABLED, String(next));
|
||
console.log(`[LiveKit аудио/видео] PreJoin toggle камера: ${next} → localStorage`);
|
||
} catch {}
|
||
return next;
|
||
});
|
||
}}
|
||
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: videoEnabled ? 'var(--md-sys-color-primary)' : '#666', color: '#fff', cursor: 'pointer' }}
|
||
>
|
||
{videoEnabled ? 'Выключить' : 'Включить'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<button onClick={onCancel} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: '1px solid var(--md-sys-color-outline)', background: 'transparent', cursor: 'pointer' }}>
|
||
Отмена
|
||
</button>
|
||
<button onClick={handleJoin} style={{ flex: 1, padding: '14px 24px', borderRadius: 14, border: 'none', background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', fontWeight: 600, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
|
||
<span className="material-symbols-outlined">videocam</span>
|
||
Войти в конференцию
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type RoomContentProps = {
|
||
lessonId: number | null;
|
||
boardId: string | null;
|
||
boardLoading: boolean;
|
||
showBoard: boolean;
|
||
setShowBoard: (v: boolean) => void;
|
||
userDisplayName: string;
|
||
};
|
||
|
||
function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard, userDisplayName }: RoomContentProps) {
|
||
const room = useRoomContext();
|
||
const router = useRouter();
|
||
const { user } = useAuth();
|
||
const [showPlatformChat, setShowPlatformChat] = useState(false);
|
||
const [lessonChat, setLessonChat] = useState<Chat | null>(null);
|
||
const [lessonChatLoading, setLessonChatLoading] = useState(false);
|
||
const [showExitModal, setShowExitModal] = useState(false);
|
||
const [showNavMenu, setShowNavMenu] = useState(false);
|
||
const [navBadges, setNavBadges] = useState<NavBadges | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
getNavBadges().then(setNavBadges).catch(() => setNavBadges(null));
|
||
}, [user]);
|
||
|
||
// Фиксируем подключение ментора/студента для метрик (lessonId — резервный поиск комнаты)
|
||
useEffect(() => {
|
||
const onConnected = () => {
|
||
if (room.name || lessonId)
|
||
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
|
||
};
|
||
room.on(RoomEvent.Connected, onConnected);
|
||
if (room.state === 'connected' && (room.name || lessonId))
|
||
participantConnected({ roomName: room.name || '', lessonId: lessonId ?? undefined }).catch(() => {});
|
||
return () => {
|
||
room.off(RoomEvent.Connected, onConnected);
|
||
};
|
||
}, [room]);
|
||
|
||
useEffect(() => {
|
||
if (!showPlatformChat || !lessonId) {
|
||
if (!showPlatformChat) setLessonChat(null);
|
||
return;
|
||
}
|
||
setLessonChatLoading(true);
|
||
getOrCreateLessonChat(lessonId)
|
||
.then((c) => setLessonChat(c))
|
||
.catch(() => setLessonChat(null))
|
||
.finally(() => setLessonChatLoading(false));
|
||
}, [showPlatformChat, lessonId]);
|
||
|
||
// Вставляем бургер (слева от микрофона) и кнопку «Выйти» в панель LiveKit
|
||
useEffect(() => {
|
||
if (showBoard) return;
|
||
const id = setTimeout(() => {
|
||
const bar = document.querySelector('.lk-control-bar');
|
||
if (!bar) return;
|
||
if (!bar.querySelector('.lk-burger-button')) {
|
||
const burger = document.createElement('button');
|
||
burger.type = 'button';
|
||
burger.className = 'lk-button lk-burger-button';
|
||
burger.title = 'Меню';
|
||
burger.innerHTML = '<span class="material-symbols-outlined" style="font-size: 20px; width: 20px; height: 20px;">menu</span>';
|
||
burger.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-burger-click')));
|
||
bar.insertBefore(burger, bar.firstChild);
|
||
}
|
||
if (!bar.querySelector('.lk-custom-exit-button')) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'lk-button lk-custom-exit-button';
|
||
btn.setAttribute('data-lk-source', 'disconnect');
|
||
btn.title = 'Выйти';
|
||
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size: 16px; width: 16px; height: 16px;">logout</span>';
|
||
btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
|
||
bar.appendChild(btn);
|
||
}
|
||
}, 800);
|
||
return () => clearTimeout(id);
|
||
}, [showBoard]);
|
||
|
||
useEffect(() => {
|
||
const handler = () => setShowNavMenu((v) => !v);
|
||
window.addEventListener('livekit-burger-click', handler);
|
||
return () => window.removeEventListener('livekit-burger-click', handler);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handler = () => {
|
||
if (user?.role === 'mentor') {
|
||
setShowExitModal(true);
|
||
} else {
|
||
room.disconnect();
|
||
}
|
||
};
|
||
window.addEventListener('livekit-exit-click', handler);
|
||
return () => window.removeEventListener('livekit-exit-click', handler);
|
||
}, [user?.role, room]);
|
||
|
||
// Сохраняем в localStorage при переключении микрофона/камеры + логи для отладки
|
||
useEffect(() => {
|
||
const lp = room.localParticipant;
|
||
if (!lp) return;
|
||
const log = (source: string, audio: boolean, video: boolean) => {
|
||
console.log(`[LiveKit аудио/видео] ${source}: audio=${audio}, video=${video} → localStorage.${LS_AUDIO_ENABLED}=${audio}, ${LS_VIDEO_ENABLED}=${video}`);
|
||
};
|
||
const saveFromState = (source: string) => {
|
||
const audio = lp.isMicrophoneEnabled;
|
||
const video = lp.isCameraEnabled;
|
||
try {
|
||
localStorage.setItem(LS_AUDIO_ENABLED, String(audio));
|
||
localStorage.setItem(LS_VIDEO_ENABLED, String(video));
|
||
log(source, audio, video);
|
||
} catch (e) {
|
||
console.error('[LiveKit аудио/видео] Ошибка сохранения:', e);
|
||
}
|
||
};
|
||
const onTrackMuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
|
||
if (participant?.sid !== lp.sid) return;
|
||
try {
|
||
if (pub?.source === Track.Source.Microphone) {
|
||
localStorage.setItem(LS_AUDIO_ENABLED, 'false');
|
||
log('TrackMuted(Microphone)', false, lp.isCameraEnabled);
|
||
} else if (pub?.source === Track.Source.Camera) {
|
||
localStorage.setItem(LS_VIDEO_ENABLED, 'false');
|
||
log('TrackMuted(Camera)', lp.isMicrophoneEnabled, false);
|
||
} else {
|
||
saveFromState('TrackMuted(?)');
|
||
}
|
||
} catch (e) {
|
||
console.error('[LiveKit аудио/видео] TrackMuted ошибка:', e);
|
||
}
|
||
};
|
||
const onTrackUnmuted = (pub: { source?: Track.Source }, participant: { sid?: string }) => {
|
||
if (participant?.sid !== lp.sid) return;
|
||
try {
|
||
if (pub?.source === Track.Source.Microphone) {
|
||
localStorage.setItem(LS_AUDIO_ENABLED, 'true');
|
||
log('TrackUnmuted(Microphone)', true, lp.isCameraEnabled);
|
||
} else if (pub?.source === Track.Source.Camera) {
|
||
localStorage.setItem(LS_VIDEO_ENABLED, 'true');
|
||
log('TrackUnmuted(Camera)', lp.isMicrophoneEnabled, true);
|
||
} else {
|
||
saveFromState('TrackUnmuted(?)');
|
||
}
|
||
} catch (e) {
|
||
console.error('[LiveKit аудио/видео] TrackUnmuted ошибка:', e);
|
||
}
|
||
};
|
||
const onLocalTrackPublished = () => saveFromState('LocalTrackPublished');
|
||
room.on(RoomEvent.TrackMuted, onTrackMuted);
|
||
room.on(RoomEvent.TrackUnmuted, onTrackUnmuted);
|
||
room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
|
||
saveFromState('init');
|
||
return () => {
|
||
room.off(RoomEvent.TrackMuted, onTrackMuted);
|
||
room.off(RoomEvent.TrackUnmuted, onTrackUnmuted);
|
||
room.off(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
|
||
/* Не сохраняем при unmount/Disconnected — треки уже удалены, lp.isMicrophoneEnabled вернёт false */
|
||
};
|
||
}, [room]);
|
||
|
||
return (
|
||
<div style={{ height: '100vh', position: 'relative', display: 'flex' }}>
|
||
<StartAudioOverlay />
|
||
<RemoteParticipantAvatarPlaceholder />
|
||
<div style={{ flex: 1, position: 'relative', background: '#000' }}>
|
||
{/* Boundary только вокруг VideoConference — при reset доска не перемонтируется */}
|
||
<div
|
||
style={{
|
||
position: 'absolute',
|
||
inset: 0,
|
||
zIndex: showBoard ? 0 : 1,
|
||
}}
|
||
>
|
||
<LiveKitLayoutErrorBoundary>
|
||
<VideoConference chatMessageFormatter={(message) => message} />
|
||
</LiveKitLayoutErrorBoundary>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Сайдбар, PiP, бургер и навигация в Portal */}
|
||
{typeof document !== 'undefined' && createPortal(
|
||
<>
|
||
{showBoard && <RemoteParticipantPiP chatOpen={showPlatformChat} />}
|
||
{/* Бургер при открытой доске — панель скрыта, показываем свой бургер */}
|
||
{showBoard && (
|
||
<button
|
||
onClick={() => setShowNavMenu((v) => !v)}
|
||
style={{
|
||
position: 'fixed',
|
||
left: 16,
|
||
bottom: 64,
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: 'rgba(0,0,0,0.7)',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
zIndex: 10002,
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||
}}
|
||
title="Меню"
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>menu</span>
|
||
</button>
|
||
)}
|
||
{/* Выдвижная навигация — BottomNavigationBar слева, 3 колонки */}
|
||
{showNavMenu && (
|
||
<>
|
||
<div
|
||
onClick={() => setShowNavMenu(false)}
|
||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)' }}
|
||
/>
|
||
<Suspense fallback={null}>
|
||
<BottomNavigationBar
|
||
userRole={user?.role}
|
||
user={user}
|
||
navBadges={navBadges}
|
||
slideout
|
||
onClose={() => setShowNavMenu(false)}
|
||
/>
|
||
</Suspense>
|
||
</>
|
||
)}
|
||
<div
|
||
data-lk-sidebar="camera-board-chat"
|
||
style={{
|
||
position: 'fixed',
|
||
right: showPlatformChat ? CHAT_PANEL_WIDTH + 16 : 16,
|
||
top: '50%',
|
||
transform: 'translateY(-50%)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 8,
|
||
padding: 8,
|
||
background: 'rgba(0,0,0,0.7)',
|
||
borderRadius: 12,
|
||
zIndex: 10001,
|
||
}}
|
||
>
|
||
<button
|
||
onClick={() => setShowBoard(false)}
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: !showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
title="Камера"
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>videocam</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => (boardId && !boardLoading ? setShowBoard(true) : undefined)}
|
||
disabled={!boardId || boardLoading}
|
||
aria-disabled={!boardId || boardLoading}
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: showBoard ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
|
||
color: '#fff',
|
||
cursor: boardId && !boardLoading ? 'pointer' : 'not-allowed',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
opacity: !boardId || boardLoading ? 0.6 : 1,
|
||
pointerEvents: !boardId || boardLoading ? 'none' : 'auto',
|
||
}}
|
||
title={
|
||
boardLoading
|
||
? 'Загрузка доски...'
|
||
: !boardId
|
||
? 'Доска недоступна (проверяем каждые 10 с)'
|
||
: 'Доска'
|
||
}
|
||
>
|
||
{boardLoading ? (
|
||
<LoadingSpinner size="small" inline />
|
||
) : (
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>draw</span>
|
||
)}
|
||
</button>
|
||
{lessonId != null && (
|
||
<button
|
||
onClick={() => setShowPlatformChat((v) => !v)}
|
||
style={{
|
||
width: 48,
|
||
height: 48,
|
||
borderRadius: 12,
|
||
border: 'none',
|
||
background: showPlatformChat ? 'var(--md-sys-color-primary)' : 'rgba(255,255,255,0.2)',
|
||
color: '#fff',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
title="Чат"
|
||
>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>chat</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</>,
|
||
document.body
|
||
)}
|
||
|
||
{/* Панель чата сервиса (не LiveKit) */}
|
||
{showPlatformChat && (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
right: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
width: `min(${CHAT_PANEL_WIDTH}px, 100vw)`,
|
||
background: 'var(--md-sys-color-surface)',
|
||
boxShadow: '-4px 0 24px rgba(0,0,0,0.3)',
|
||
zIndex: 10001,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{lessonChatLoading ? (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||
<span className="material-symbols-outlined" style={{ fontSize: 32, opacity: 0.5 }}>progress_activity</span>
|
||
</div>
|
||
) : (
|
||
<ChatWindow
|
||
chat={lessonChat}
|
||
currentUserId={user?.id ?? null}
|
||
onBack={() => setShowPlatformChat(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ExitLessonModal
|
||
isOpen={showExitModal}
|
||
lessonId={lessonId}
|
||
onClose={() => setShowExitModal(false)}
|
||
onExit={() => room.disconnect()}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const SS_PREJOIN_DONE = 'livekit_prejoin_done';
|
||
|
||
/** PreJoin — показываем при первом заходе. После входа в комнату или перезагрузки — пропускаем (sessionStorage). */
|
||
function getInitialShowPreJoin(searchParams: URLSearchParams): boolean {
|
||
try {
|
||
if (typeof window !== 'undefined' && sessionStorage.getItem(SS_PREJOIN_DONE) === '1') {
|
||
return false;
|
||
}
|
||
return searchParams.get('skip_prejoin') !== '1';
|
||
} catch {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
function getSavedAudioVideo(): { audio: boolean; video: boolean } {
|
||
try {
|
||
const rawA = localStorage.getItem(LS_AUDIO_ENABLED);
|
||
const rawV = localStorage.getItem(LS_VIDEO_ENABLED);
|
||
const audio = rawA !== 'false';
|
||
const video = rawV !== 'false';
|
||
console.log(`[LiveKit аудио/видео] Чтение из localStorage: ${LS_AUDIO_ENABLED}=${rawA} → audio=${audio}, ${LS_VIDEO_ENABLED}=${rawV} → video=${video}`);
|
||
return { audio, video };
|
||
} catch (e) {
|
||
console.error('[LiveKit аудио/видео] Ошибка чтения localStorage:', e);
|
||
return { audio: true, video: true };
|
||
}
|
||
}
|
||
|
||
export default function LiveKitRoomContent() {
|
||
const router = useRouter();
|
||
const searchParams = useSearchParams();
|
||
const accessToken = searchParams.get('token');
|
||
const lessonIdParam = searchParams.get('lesson_id');
|
||
const { user } = useAuth();
|
||
|
||
const [serverUrl, setServerUrl] = useState<string>('');
|
||
const [showPreJoin, setShowPreJoin] = useState(() => getInitialShowPreJoin(searchParams));
|
||
const [audioEnabled, setAudioEnabled] = useState(true);
|
||
const [videoEnabled, setVideoEnabled] = useState(true);
|
||
const [avReady, setAvReady] = useState(false);
|
||
|
||
// Подтягиваем из localStorage после монтирования (SSR не имеет доступа к LS)
|
||
useEffect(() => {
|
||
const saved = getSavedAudioVideo();
|
||
console.log('[LiveKit аудио/видео] useEffect: загрузили из localStorage, устанавливаем state:', saved);
|
||
setAudioEnabled(saved.audio);
|
||
setVideoEnabled(saved.video);
|
||
setAvReady(true);
|
||
}, []);
|
||
const [lessonCompleted, setLessonCompleted] = useState(false);
|
||
const [effectiveLessonId, setEffectiveLessonId] = useState<number | null>(null);
|
||
const [boardId, setBoardId] = useState<string | null>(null);
|
||
const [boardLoading, setBoardLoading] = useState(false);
|
||
const boardPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
const [isMentor, setIsMentor] = useState(false);
|
||
const [showBoard, setShowBoard] = useState(false);
|
||
const [userDisplayName, setUserDisplayName] = useState('Пользователь');
|
||
|
||
// Доска и is_mentor из metadata LiveKit токена или getOrCreateLessonBoard; при отсутствии доски — опрос раз в 10 с
|
||
useEffect(() => {
|
||
const meta = getTokenMetadata(accessToken);
|
||
if (meta.is_mentor === true) setIsMentor(true);
|
||
if (meta.board_id) {
|
||
setBoardId(meta.board_id);
|
||
setBoardLoading(false);
|
||
return;
|
||
}
|
||
if (!effectiveLessonId) {
|
||
setBoardLoading(false);
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const stopPoll = () => {
|
||
if (boardPollRef.current) {
|
||
clearInterval(boardPollRef.current);
|
||
boardPollRef.current = null;
|
||
}
|
||
};
|
||
setBoardLoading(true);
|
||
getOrCreateLessonBoard(effectiveLessonId)
|
||
.then((b) => {
|
||
if (!cancelled) {
|
||
setBoardId(b.board_id);
|
||
stopPoll();
|
||
}
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => { if (!cancelled) setBoardLoading(false); });
|
||
|
||
boardPollRef.current = setInterval(() => {
|
||
if (cancelled) return;
|
||
getOrCreateLessonBoard(effectiveLessonId)
|
||
.then((b) => {
|
||
if (!cancelled) {
|
||
setBoardId(b.board_id);
|
||
stopPoll();
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, 10_000);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
stopPoll();
|
||
};
|
||
}, [accessToken, effectiveLessonId]);
|
||
|
||
// Fallback: is_mentor из API урока (lesson.mentor.id === user.id), если токен старый или без metadata
|
||
useEffect(() => {
|
||
if (!effectiveLessonId || !user) return;
|
||
const userId = user.id ?? (user as { pk?: number }).pk;
|
||
if (!userId) return;
|
||
getLesson(String(effectiveLessonId))
|
||
.then((lesson) => {
|
||
const mentorId = typeof lesson.mentor === 'object' && lesson.mentor
|
||
? Number(lesson.mentor.id)
|
||
: Number(lesson.mentor);
|
||
if (mentorId && Number(userId) === mentorId) {
|
||
setIsMentor(true);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, [effectiveLessonId, user]);
|
||
|
||
useEffect(() => {
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
|
||
fetch(`${base}/profile/me/`, { headers: { Authorization: `Bearer ${token}` } })
|
||
.then((r) => r.json())
|
||
.then((u: { first_name?: string; last_name?: string; email?: string }) => {
|
||
const raw = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || 'Пользователь';
|
||
const name = /%[0-9A-Fa-f]{2}/.test(raw) ? decodeURIComponent(raw) : raw;
|
||
setUserDisplayName(name);
|
||
})
|
||
.catch(() => {});
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const id = lessonIdParam ? parseInt(lessonIdParam, 10) : null;
|
||
if (id && !isNaN(id)) {
|
||
setEffectiveLessonId(id);
|
||
try {
|
||
sessionStorage.setItem('livekit_lesson_id', String(id));
|
||
} catch {}
|
||
} else {
|
||
try {
|
||
const stored = sessionStorage.getItem('livekit_lesson_id');
|
||
if (stored) setEffectiveLessonId(parseInt(stored, 10));
|
||
} catch {}
|
||
}
|
||
}, [lessonIdParam]);
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
if (lessonIdParam) {
|
||
try {
|
||
const l = await getLesson(lessonIdParam);
|
||
if (l.status === 'completed') {
|
||
const now = new Date();
|
||
const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
|
||
if (now > new Date(end.getTime() + 10 * 60000)) setLessonCompleted(true);
|
||
}
|
||
} catch {}
|
||
}
|
||
const token = localStorage.getItem('access_token');
|
||
if (token) {
|
||
try {
|
||
const base = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8123/api';
|
||
const res = await fetch(`${base}/video/livekit/config/`, { headers: { Authorization: `Bearer ${token}` } });
|
||
if (res.ok) {
|
||
const config = await res.json();
|
||
setServerUrl(config.server_url || 'ws://127.0.0.1:7880');
|
||
}
|
||
} catch {}
|
||
}
|
||
};
|
||
load();
|
||
}, [lessonIdParam]);
|
||
|
||
if (lessonCompleted) {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--md-sys-color-surface)' }}>
|
||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||
<p style={{ fontSize: 18, marginBottom: 16 }}>Урок завершён. Видеоконференция недоступна.</p>
|
||
<button onClick={() => router.push('/dashboard')} style={{ padding: '12px 24px', borderRadius: 12, border: 'none', background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', 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 {}
|
||
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>
|
||
);
|
||
}
|
||
|
||
console.log('[LiveKit аудио/видео] Передача в LiveKitRoom:', { audio: audioEnabled, video: videoEnabled });
|
||
return (
|
||
<>
|
||
{/* Доска — сиблинг LiveKitRoom, iframe создаётся один раз, не перемонтируется */}
|
||
{boardId && (
|
||
<div
|
||
key="board-layer"
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: showBoard ? 9999 : 0,
|
||
pointerEvents: showBoard ? 'auto' : 'none',
|
||
}}
|
||
>
|
||
<WhiteboardIframe key={boardId} boardId={boardId} username={userDisplayName} showingBoard={showBoard} isMentor={isMentor} />
|
||
</div>
|
||
)}
|
||
<LiveKitRoom
|
||
token={accessToken}
|
||
serverUrl={serverUrl}
|
||
connect={true}
|
||
audio={audioEnabled}
|
||
video={videoEnabled}
|
||
onDisconnected={() => router.push('/dashboard')}
|
||
style={{ height: '100vh' }}
|
||
data-lk-theme="default"
|
||
options={{
|
||
adaptiveStream: true,
|
||
dynacast: true,
|
||
// Захват до 2K (1440p), при отсутствии поддержки браузер даст меньше
|
||
videoCaptureDefaults: {
|
||
resolution: PRESET_2K.resolution,
|
||
frameRate: 30,
|
||
},
|
||
publishDefaults: {
|
||
simulcast: true,
|
||
// Камера: до 2K, 6 Mbps — вариативность через слои 1080p, 720p, 360p
|
||
videoEncoding: PRESET_2K.encoding,
|
||
// Два слоя поверх основного: 720p и 360p для вариативности при слабом канале
|
||
videoSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||
// Демонстрация экрана: 2K, 6 Mbps, те же два слоя для адаптации
|
||
screenShareEncoding: { maxBitrate: 6_000_000, maxFramerate: 30 },
|
||
screenShareSimulcastLayers: [VideoPresets.h720, VideoPresets.h360],
|
||
degradationPreference: 'maintain-resolution',
|
||
},
|
||
audioCaptureDefaults: {
|
||
noiseSuppression: true,
|
||
echoCancellation: true,
|
||
},
|
||
}}
|
||
>
|
||
<RoomContent
|
||
lessonId={effectiveLessonId}
|
||
boardId={boardId}
|
||
boardLoading={boardLoading}
|
||
showBoard={showBoard}
|
||
setShowBoard={setShowBoard}
|
||
userDisplayName={userDisplayName}
|
||
/>
|
||
<RoomAudioRenderer />
|
||
<ConnectionStateToast />
|
||
</LiveKitRoom>
|
||
</>
|
||
);
|
||
}
|