uchill/front_material/components/livekit/LiveKitRoomContent.tsx

1063 lines
41 KiB
TypeScript
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';
/**
* 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 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]);
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>
</>
);
}