798 lines
31 KiB
JavaScript
798 lines
31 KiB
JavaScript
import React,{useCallback,useEffect,useMemo,useRef,useState}from'react';
|
||
import{createRoot}from'react-dom/client';
|
||
import{Room,RoomEvent,Track}from'livekit-client';
|
||
import QRCode from'qrcode';
|
||
import'./styles.css';
|
||
|
||
|
||
const ICONS = {
|
||
attachment: '/icons/attachment.png',
|
||
cameraOff: '/icons/camera_off.png',
|
||
cameraOn: '/icons/camera_on.png',
|
||
micOff: '/icons/micro_off.png',
|
||
micOn: '/icons/micro_on.png'
|
||
};
|
||
|
||
|
||
function apiUrl(path) {
|
||
return path;
|
||
}
|
||
|
||
|
||
async function readJson(response) {
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.detail || 'Ошибка запроса');
|
||
return data;
|
||
}
|
||
|
||
|
||
function routeFromLocation() {
|
||
const path = window.location.pathname;
|
||
if (path === '/') return { name: 'home' };
|
||
if (path === '/call/overloaded') return { name: 'error', code: '503' };
|
||
const match = path.match(/^\/call\/([^/]+)$/);
|
||
if (match) return { name: 'call', roomName: decodeURIComponent(match[1]) };
|
||
return { name: 'error', code: '404' };
|
||
}
|
||
|
||
|
||
function getInviteToken() {
|
||
return new URL(window.location.href).searchParams.get('invite') || '';
|
||
}
|
||
|
||
|
||
function randomGuestName() {
|
||
const a = Date.now().toString(36).slice(-4);
|
||
const b = Math.random().toString(36).slice(2, 6);
|
||
return `Гость-${a}${b}`.slice(0, 24);
|
||
}
|
||
|
||
|
||
function instaRoomTitle() {
|
||
return `Инста-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
|
||
function Toasts({ items }) {
|
||
return (
|
||
<div className='toast-stack'>
|
||
{items.map((toast) => (
|
||
<div className={`toast ${toast.type}`} key={toast.id}>{toast.message}</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function useToasts() {
|
||
const [items, setItems] = useState([]);
|
||
|
||
const showToast = useCallback((message, type = 'info') => {
|
||
const id = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
|
||
setItems((current) => current.concat({ id, message, type }));
|
||
window.setTimeout(() => {
|
||
setItems((current) => current.filter((item) => item.id !== id));
|
||
}, 2600);
|
||
}, []);
|
||
|
||
return [items, showToast];
|
||
}
|
||
|
||
|
||
function ErrorPage({ code }) {
|
||
const copy = {
|
||
'403': ['Приглашение недействительно', 'Ссылка приглашения недействительна или была заменена. Запросите новую ссылку у организатора.'],
|
||
'410': ['Звонок завершён', 'Эта встреча уже закончена. Попросите организатора прислать новую ссылку.'],
|
||
'503': ['Сервис временно недоступен', 'Сервер перегружен. Подождите немного и обновите страницу или зайдите позже.'],
|
||
'404': ['Комната не найдена', 'Такой страницы нет или ссылка устарела. Проверьте адрес или создайте новый звонок на главной странице.']
|
||
};
|
||
const [title, message] = copy[code] || copy['404'];
|
||
|
||
useEffect(() => {
|
||
document.title = `${title} · Linkra`;
|
||
}, [title]);
|
||
|
||
return (
|
||
<main className='container error-page'>
|
||
<section className='card error-card'>
|
||
<p className='error-code'>{code}</p>
|
||
<h1>{title}</h1>
|
||
<p className='muted'>{message}</p>
|
||
<div className='row centered'>
|
||
<a className='btn primary' href='/'>На главную</a>
|
||
{code === '503' ? <button className='btn secondary' type='button' onClick={() => window.location.reload()}>Обновить страницу</button> : null}
|
||
</div>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|
||
function HomePage({ showToast }) {
|
||
const [roomTitle, setRoomTitle] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const [instaLoading, setInstaLoading] = useState(false);
|
||
const [result, setResult] = useState(null);
|
||
const qrRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
document.title = 'Созвон по ссылке · Linkra';
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!result || !qrRef.current) return;
|
||
const target = result.short_invite_link || result.invite_link;
|
||
QRCode.toCanvas(qrRef.current, target, { width: 200, margin: 1 }).catch(() => {});
|
||
}, [result]);
|
||
|
||
const createCall = async ({ quickJoin = false } = {}) => {
|
||
const title = quickJoin ? instaRoomTitle() : roomTitle.trim();
|
||
const roomPassword = quickJoin ? '' : password.trim();
|
||
if (!quickJoin && title.length < 4) {
|
||
showToast('Название комнаты должно содержать минимум 4 символа', 'error');
|
||
return null;
|
||
}
|
||
if (!quickJoin && roomPassword.length > 0 && roomPassword.length < 4) {
|
||
showToast('Пароль должен содержать минимум 4 символа', 'error');
|
||
return null;
|
||
}
|
||
|
||
const setBusy = quickJoin ? setInstaLoading : setLoading;
|
||
setBusy(true);
|
||
try {
|
||
const response = await fetch(apiUrl('/api/calls'), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
room_title: title,
|
||
password: roomPassword,
|
||
quick_join: quickJoin
|
||
})
|
||
});
|
||
return await readJson(response);
|
||
} catch (error) {
|
||
showToast(error.message || 'Не удалось создать звонок', 'error');
|
||
return null;
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleCreate = async () => {
|
||
const data = await createCall();
|
||
if (!data) return;
|
||
setResult(data);
|
||
showToast('Ссылка на звонок готова', 'success');
|
||
};
|
||
|
||
const handleInsta = async () => {
|
||
const data = await createCall({ quickJoin: true });
|
||
if (!data) return;
|
||
const target = data.short_invite_link || data.invite_link;
|
||
sessionStorage.setItem(`linkra-insta:${data.room_name}`, JSON.stringify({
|
||
invite_token: data.invite_token,
|
||
display_name: randomGuestName(),
|
||
mic_enabled: false,
|
||
camera_enabled: false
|
||
}));
|
||
await navigator.clipboard.writeText(target).catch(() => {});
|
||
window.location.href = target;
|
||
};
|
||
|
||
const copyText = async (value) => {
|
||
await navigator.clipboard.writeText(value);
|
||
showToast('Ссылка скопирована', 'success');
|
||
};
|
||
|
||
return (
|
||
<main className='container home-shell'>
|
||
<div className='home-grid'>
|
||
<header className='home-hero'>
|
||
<h1>Созвон по ссылке</h1>
|
||
<p className='muted home-hero-lead'>Создай комнату, отправь ссылку собеседнику: видео, чат и демонстрация экрана в браузере, без приложений.</p>
|
||
<ul className='home-features'>
|
||
<li>Камера и микрофон без установки</li>
|
||
<li>Чат и файлы в комнате</li>
|
||
<li>Короткая ссылка и QR для телефона</li>
|
||
<li>Кнопка «Инста» для быстрого входа</li>
|
||
</ul>
|
||
</header>
|
||
|
||
<section className='card home-card'>
|
||
<div className='home-card-header'>
|
||
<h2>Новая комната</h2>
|
||
</div>
|
||
|
||
{!result ? (
|
||
<div className='stack'>
|
||
<label className='field'>
|
||
<span>Название комнаты</span>
|
||
<input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} type='text' placeholder='Например, Планёрка команды' maxLength='100' />
|
||
</label>
|
||
<label className='field'>
|
||
<span>Пароль комнаты <small className='muted'>(необязательно)</small></span>
|
||
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Например, team2026' maxLength='100' />
|
||
</label>
|
||
<div className='home-actions'>
|
||
<button className='btn primary' type='button' disabled={loading} onClick={handleCreate}>{loading ? 'Создаю...' : 'Создать звонок'}</button>
|
||
<button className='btn secondary' type='button' disabled={instaLoading} onClick={handleInsta}>{instaLoading ? 'Создаём...' : 'Инста'}</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className='stack'>
|
||
<label className='field'>
|
||
<span>Ссылка приглашения</span>
|
||
<input value={result.short_invite_link || result.invite_link} readOnly onFocus={(event) => event.target.select()} />
|
||
</label>
|
||
<button className='btn primary' type='button' onClick={() => copyText(result.short_invite_link || result.invite_link)}>Копировать</button>
|
||
<details>
|
||
<summary>Показать полную ссылку и QR-код</summary>
|
||
<label className='field'>
|
||
<span>Полная ссылка</span>
|
||
<input value={result.invite_link} readOnly onFocus={(event) => event.target.select()} />
|
||
</label>
|
||
<button className='btn secondary' type='button' onClick={() => copyText(result.invite_link)}>Копировать полную</button>
|
||
<div className='qr-box'><canvas ref={qrRef}></canvas></div>
|
||
</details>
|
||
<p className='muted'>Название комнаты: {result.room_title}</p>
|
||
<a className='btn primary link-btn' href={result.short_invite_link || result.invite_link}>Перейти в звонок</a>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|
||
function MediaTrack({ track, muted = false, className = '' }) {
|
||
const ref = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!track || !ref.current) return undefined;
|
||
track.attach(ref.current);
|
||
return () => {
|
||
track.detach(ref.current);
|
||
};
|
||
}, [track]);
|
||
|
||
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
||
}
|
||
|
||
|
||
function RemoteAudioTrack({ track, volume }) {
|
||
const gainRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
if (!track?.mediaStreamTrack) return undefined;
|
||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioContextClass) return undefined;
|
||
const context = new AudioContextClass();
|
||
const stream = new MediaStream([track.mediaStreamTrack]);
|
||
const source = context.createMediaStreamSource(stream);
|
||
const gain = context.createGain();
|
||
gain.gain.value = volume;
|
||
source.connect(gain);
|
||
gain.connect(context.destination);
|
||
gainRef.current = gain;
|
||
context.resume().catch(() => {});
|
||
|
||
return () => {
|
||
source.disconnect();
|
||
gain.disconnect();
|
||
gainRef.current = null;
|
||
context.close().catch(() => {});
|
||
};
|
||
}, [track]);
|
||
|
||
useEffect(() => {
|
||
if (gainRef.current) gainRef.current.gain.value = volume;
|
||
}, [volume]);
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
function VideoTile({ item, active, volume, onVolumeChange }) {
|
||
const camera = item.tracks.find((entry) => entry.kind === 'video' && entry.source !== Track.Source.ScreenShare);
|
||
const screen = item.tracks.find((entry) => entry.kind === 'video' && entry.source === Track.Source.ScreenShare);
|
||
const audio = item.tracks.filter((entry) => entry.kind === 'audio');
|
||
const initial = (item.name || '?').trim().charAt(0).toUpperCase() || '?';
|
||
const volumePercent = Math.round(volume * 100);
|
||
|
||
return (
|
||
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
|
||
<div className='video-label-row'>
|
||
<span>{item.name}</span>
|
||
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
|
||
</div>
|
||
{!item.isLocal ? (
|
||
<label className='participant-volume'>
|
||
<span>Громкость: {volumePercent}%</span>
|
||
<input
|
||
aria-label={`Громкость участника ${item.name}`}
|
||
max='2'
|
||
min='0'
|
||
onChange={(event) => onVolumeChange(item.identity, Number(event.target.value))}
|
||
step='0.05'
|
||
type='range'
|
||
value={volume}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
{screen ? (
|
||
<div className='screen-slot'>
|
||
<MediaTrack track={screen.track} className='screen-share-video' muted={item.isLocal} />
|
||
</div>
|
||
) : null}
|
||
<div className='media-slot'>
|
||
{camera ? <MediaTrack track={camera.track} className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
|
||
{audio.map((entry) => item.isLocal ? null : <RemoteAudioTrack key={entry.id} track={entry.track} volume={volume} />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
|
||
function CallPage({ roomName, showToast }) {
|
||
const [call, setCall] = useState(null);
|
||
const [loadError, setLoadError] = useState(null);
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [micEnabled, setMicEnabled] = useState(true);
|
||
const [cameraEnabled, setCameraEnabled] = useState(true);
|
||
const [joining, setJoining] = useState(false);
|
||
const [room, setRoom] = useState(null);
|
||
const [participants, setParticipants] = useState([]);
|
||
const [participantVolumes, setParticipantVolumes] = useState({});
|
||
const [activeSpeakers, setActiveSpeakers] = useState([]);
|
||
const [messages, setMessages] = useState([]);
|
||
const [chatText, setChatText] = useState('');
|
||
const [config, setConfig] = useState({ max_attachment_size_mb: 100, turn_ice_servers: [] });
|
||
const [elapsed, setElapsed] = useState(0);
|
||
const previewRef = useRef(null);
|
||
const previewStreamRef = useRef(null);
|
||
const fileRef = useRef(null);
|
||
|
||
const inviteToken = useMemo(getInviteToken, []);
|
||
const joined = !!room;
|
||
|
||
const refreshParticipants = useCallback((nextRoom) => {
|
||
if (!nextRoom) return;
|
||
const mapParticipant = (participant, isLocal) => {
|
||
const tracks = Array.from(participant.trackPublications.values())
|
||
.filter((publication) => publication.track)
|
||
.map((publication) => ({
|
||
id: publication.trackSid || publication.track.sid || `${participant.identity}-${publication.source}`,
|
||
track: publication.track,
|
||
source: publication.source,
|
||
kind: publication.kind || publication.track.kind
|
||
}));
|
||
return {
|
||
identity: participant.identity,
|
||
name: participant.name || participant.identity,
|
||
isLocal,
|
||
tracks
|
||
};
|
||
};
|
||
setParticipants([
|
||
mapParticipant(nextRoom.localParticipant, true),
|
||
...Array.from(nextRoom.remoteParticipants.values()).map((participant) => mapParticipant(participant, false))
|
||
]);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}`))
|
||
.then(readJson)
|
||
.then((data) => {
|
||
if (!data.is_active) {
|
||
setLoadError('410');
|
||
return;
|
||
}
|
||
setCall(data);
|
||
document.title = `${data.room_title} · Linkra`;
|
||
})
|
||
.catch(() => setLoadError('404'));
|
||
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
|
||
}, [roomName]);
|
||
|
||
useEffect(() => {
|
||
const raw = sessionStorage.getItem(`linkra-insta:${roomName}`);
|
||
if (!raw) return;
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
if (data.invite_token && data.invite_token !== inviteToken) return;
|
||
setDisplayName(data.display_name || randomGuestName());
|
||
setMicEnabled(Boolean(data.mic_enabled));
|
||
setCameraEnabled(Boolean(data.camera_enabled));
|
||
} catch {
|
||
return;
|
||
}
|
||
}, [inviteToken, roomName]);
|
||
|
||
useEffect(() => {
|
||
if (joined || !navigator.mediaDevices) return undefined;
|
||
let cancelled = false;
|
||
|
||
const run = async () => {
|
||
if (previewStreamRef.current) {
|
||
previewStreamRef.current.getTracks().forEach((track) => track.stop());
|
||
previewStreamRef.current = null;
|
||
}
|
||
if (!cameraEnabled && !micEnabled) {
|
||
if (previewRef.current) previewRef.current.srcObject = null;
|
||
return;
|
||
}
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: cameraEnabled });
|
||
if (cancelled) {
|
||
stream.getTracks().forEach((track) => track.stop());
|
||
return;
|
||
}
|
||
previewStreamRef.current = stream;
|
||
if (previewRef.current) previewRef.current.srcObject = cameraEnabled ? new MediaStream(stream.getVideoTracks()) : null;
|
||
} catch {
|
||
showToast('Не удалось получить доступ к камере или микрофону', 'error');
|
||
}
|
||
};
|
||
|
||
run();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [cameraEnabled, joined, micEnabled, showToast]);
|
||
|
||
useEffect(() => {
|
||
if (!joined) return undefined;
|
||
const started = Date.now();
|
||
const interval = window.setInterval(() => {
|
||
setElapsed(Math.floor((Date.now() - started) / 1000));
|
||
}, 1000);
|
||
return () => window.clearInterval(interval);
|
||
}, [joined]);
|
||
|
||
useEffect(() => () => {
|
||
if (previewStreamRef.current) previewStreamRef.current.getTracks().forEach((track) => track.stop());
|
||
if (room) room.disconnect();
|
||
}, [room]);
|
||
|
||
const publishData = async (payload) => {
|
||
if (!room) return;
|
||
const encoded = new TextEncoder().encode(JSON.stringify(payload));
|
||
await room.localParticipant.publishData(encoded, { reliable: true });
|
||
};
|
||
|
||
const updateParticipantVolume = useCallback((identity, volume) => {
|
||
const safeVolume = Math.min(2, Math.max(0, volume));
|
||
setParticipantVolumes((current) => ({
|
||
...current,
|
||
[identity]: safeVolume
|
||
}));
|
||
}, []);
|
||
|
||
const joinCall = async () => {
|
||
const name = displayName.trim();
|
||
if (!name) {
|
||
showToast('Введи имя', 'error');
|
||
return;
|
||
}
|
||
if (!inviteToken) {
|
||
setLoadError('403');
|
||
return;
|
||
}
|
||
setJoining(true);
|
||
try {
|
||
const response = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/join`), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
invite_token: inviteToken,
|
||
display_name: name,
|
||
password,
|
||
with_video: cameraEnabled,
|
||
with_audio: micEnabled
|
||
})
|
||
});
|
||
const data = await readJson(response);
|
||
if (previewStreamRef.current) {
|
||
previewStreamRef.current.getTracks().forEach((track) => track.stop());
|
||
previewStreamRef.current = null;
|
||
}
|
||
const nextRoom = new Room({ adaptiveStream: true, dynacast: true });
|
||
const refresh = () => refreshParticipants(nextRoom);
|
||
nextRoom
|
||
.on(RoomEvent.TrackSubscribed, refresh)
|
||
.on(RoomEvent.TrackUnsubscribed, refresh)
|
||
.on(RoomEvent.LocalTrackPublished, refresh)
|
||
.on(RoomEvent.LocalTrackUnpublished, refresh)
|
||
.on(RoomEvent.ParticipantConnected, refresh)
|
||
.on(RoomEvent.ParticipantDisconnected, refresh)
|
||
.on(RoomEvent.ActiveSpeakersChanged, (speakers) => setActiveSpeakers(speakers.map((speaker) => speaker.identity)))
|
||
.on(RoomEvent.DataReceived, (payload, participant) => {
|
||
const text = new TextDecoder().decode(payload);
|
||
const message = JSON.parse(text);
|
||
if (message.type === 'chat') {
|
||
setMessages((current) => current.concat({
|
||
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
|
||
author: participant?.name || participant?.identity || 'Участник',
|
||
text: message.text,
|
||
own: false
|
||
}));
|
||
}
|
||
if (message.type === 'file') {
|
||
setMessages((current) => current.concat({
|
||
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
|
||
author: participant?.name || participant?.identity || 'Участник',
|
||
attachment: message.attachment,
|
||
own: false
|
||
}));
|
||
}
|
||
})
|
||
.on(RoomEvent.Disconnected, () => {
|
||
setRoom(null);
|
||
setParticipants([]);
|
||
});
|
||
const connectOptions = {};
|
||
if (config.turn_ice_servers?.length) {
|
||
connectOptions.rtcConfig = {
|
||
iceServers: config.turn_ice_servers
|
||
};
|
||
}
|
||
await nextRoom.connect(data.server_url, data.participant_token, connectOptions);
|
||
await nextRoom.localParticipant.setMicrophoneEnabled(micEnabled);
|
||
await nextRoom.localParticipant.setCameraEnabled(cameraEnabled);
|
||
setRoom(nextRoom);
|
||
refreshParticipants(nextRoom);
|
||
sessionStorage.removeItem(`linkra-insta:${roomName}`);
|
||
showToast('Ты в комнате', 'success');
|
||
} catch (error) {
|
||
showToast(error.message || 'Не удалось войти в звонок', 'error');
|
||
} finally {
|
||
setJoining(false);
|
||
}
|
||
};
|
||
|
||
const leaveCall = async () => {
|
||
if (!room) return;
|
||
await room.disconnect();
|
||
setRoom(null);
|
||
setParticipants([]);
|
||
};
|
||
|
||
const toggleMic = async () => {
|
||
if (!room) {
|
||
setMicEnabled((value) => !value);
|
||
return;
|
||
}
|
||
const next = !micEnabled;
|
||
await room.localParticipant.setMicrophoneEnabled(next);
|
||
setMicEnabled(next);
|
||
refreshParticipants(room);
|
||
};
|
||
|
||
const toggleCamera = async () => {
|
||
if (!room) {
|
||
setCameraEnabled((value) => !value);
|
||
return;
|
||
}
|
||
const next = !cameraEnabled;
|
||
await room.localParticipant.setCameraEnabled(next);
|
||
setCameraEnabled(next);
|
||
refreshParticipants(room);
|
||
};
|
||
|
||
const toggleScreen = async () => {
|
||
if (!room) return;
|
||
const enabled = Array.from(room.localParticipant.trackPublications.values()).some((publication) => publication.source === Track.Source.ScreenShare && publication.track);
|
||
await room.localParticipant.setScreenShareEnabled(!enabled);
|
||
refreshParticipants(room);
|
||
};
|
||
|
||
const finishCall = async () => {
|
||
if (!window.confirm('Завершить звонок для всех участников?')) return;
|
||
try {
|
||
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/finish`), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ invite_token: inviteToken })
|
||
}).then(readJson);
|
||
await leaveCall();
|
||
setLoadError('410');
|
||
} catch (error) {
|
||
showToast(error.message || 'Не удалось завершить звонок', 'error');
|
||
}
|
||
};
|
||
|
||
const sendChat = async () => {
|
||
const text = chatText.trim();
|
||
if (!text || !room) return;
|
||
try {
|
||
await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/chat-events`), {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
invite_token: inviteToken,
|
||
display_name: displayName.trim(),
|
||
participant_identity: room.localParticipant.identity,
|
||
text
|
||
})
|
||
}).catch(() => {});
|
||
await publishData({ type: 'chat', text });
|
||
setMessages((current) => current.concat({
|
||
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
|
||
author: `${displayName.trim()} (ты)`,
|
||
text,
|
||
own: true
|
||
}));
|
||
setChatText('');
|
||
} catch (error) {
|
||
showToast(error.message || 'Не удалось отправить сообщение', 'error');
|
||
}
|
||
};
|
||
|
||
const sendFile = async (file) => {
|
||
if (!file || !room) return;
|
||
if (file.size > config.max_attachment_size_mb * 1024 * 1024) {
|
||
showToast(`Максимальный размер файла — ${config.max_attachment_size_mb} МБ`, 'error');
|
||
return;
|
||
}
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('display_name', displayName.trim() || 'Участник');
|
||
formData.append('participant_identity', room.localParticipant.identity);
|
||
try {
|
||
const attachment = await fetch(apiUrl(`/api/calls/${encodeURIComponent(roomName)}/attachments?invite_token=${encodeURIComponent(inviteToken)}`), {
|
||
method: 'POST',
|
||
body: formData
|
||
}).then(readJson);
|
||
await publishData({ type: 'file', attachment });
|
||
setMessages((current) => current.concat({
|
||
id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
|
||
author: `${displayName.trim()} (ты)`,
|
||
attachment,
|
||
own: true
|
||
}));
|
||
showToast('Файл отправлен', 'success');
|
||
} catch (error) {
|
||
showToast(error.message || 'Не удалось загрузить файл', 'error');
|
||
} finally {
|
||
if (fileRef.current) fileRef.current.value = '';
|
||
}
|
||
};
|
||
|
||
const copyInvite = async () => {
|
||
await navigator.clipboard.writeText(window.location.href);
|
||
showToast('Ссылка скопирована', 'success');
|
||
};
|
||
|
||
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
|
||
|
||
if (loadError) return <ErrorPage code={loadError} />;
|
||
if (!call) return <main className='container'><section className='card'>Загрузка...</section></main>;
|
||
|
||
return (
|
||
<main className='container wide'>
|
||
{!joined ? (
|
||
<section className='card join-card'>
|
||
<div className='prejoin-layout'>
|
||
<div className='preview-stage'>
|
||
{cameraEnabled ? <video ref={previewRef} className='prejoin-preview-video' autoPlay playsInline muted></video> : <div className='preview-placeholder'><div className='avatar-placeholder'>?</div><p>Камера выключена</p></div>}
|
||
</div>
|
||
<div className='stack'>
|
||
<h1>{call.room_title}</h1>
|
||
<p className='muted'>Проверь камеру и микрофон перед входом в комнату.</p>
|
||
<label className='field'>
|
||
<span>Твоё имя</span>
|
||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} type='text' placeholder='Например, Артём' maxLength='50' />
|
||
</label>
|
||
{call.has_password ? (
|
||
<label className='field'>
|
||
<span>Пароль комнаты</span>
|
||
<input value={password} onChange={(event) => setPassword(event.target.value)} type='text' placeholder='Введите пароль комнаты' maxLength='100' />
|
||
</label>
|
||
) : null}
|
||
<div className='row'>
|
||
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>
|
||
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
|
||
<span>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</span>
|
||
</button>
|
||
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>
|
||
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
|
||
<span>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</span>
|
||
</button>
|
||
</div>
|
||
<button className='btn primary' type='button' disabled={joining} onClick={joinCall}>{joining ? 'Подключаем...' : 'Войти в звонок'}</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<>
|
||
<section className='card meeting-card'>
|
||
<div className='meeting-topbar'>
|
||
<div>
|
||
<div className='meeting-title'>Созвон</div>
|
||
<h1 className='meeting-room-name'>{call.room_title}</h1>
|
||
<p className='muted'>В комнате: {participants.length}</p>
|
||
</div>
|
||
<div className='meeting-controls'>
|
||
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic} aria-label={micEnabled ? 'Выключить микрофон' : 'Включить микрофон'}>
|
||
<img className='control-icon' src={micEnabled ? ICONS.micOn : ICONS.micOff} alt='' />
|
||
</button>
|
||
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera} aria-label={cameraEnabled ? 'Выключить камеру' : 'Включить камеру'}>
|
||
<img className='control-icon' src={cameraEnabled ? ICONS.cameraOn : ICONS.cameraOff} alt='' />
|
||
</button>
|
||
<button className='icon-btn' type='button' onClick={toggleScreen}>Screen</button>
|
||
<button className='btn secondary' type='button' onClick={copyInvite}>Ссылка</button>
|
||
<div className='meeting-timer'>{time}</div>
|
||
</div>
|
||
</div>
|
||
<div className='videos'>
|
||
{participants.map((item) => (
|
||
<VideoTile
|
||
active={activeSpeakers.includes(item.identity)}
|
||
item={item}
|
||
key={item.identity}
|
||
onVolumeChange={updateParticipantVolume}
|
||
volume={participantVolumes[item.identity] ?? 1}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<aside className='card chat-card'>
|
||
<h2>Чат комнаты</h2>
|
||
<div className='chat-messages'>
|
||
{messages.map((message) => (
|
||
<div className={`chat-message${message.own ? ' own' : ''}`} key={message.id}>
|
||
<div className='chat-author'>{message.author}</div>
|
||
{message.attachment ? (
|
||
<a className='file-link' href={`${message.attachment.download_url}?invite_token=${encodeURIComponent(inviteToken)}`} target='_blank' rel='noreferrer'>{message.attachment.file_name}</a>
|
||
) : <div className='chat-text'>{message.text}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className='chat-form'>
|
||
<input value={chatText} onChange={(event) => setChatText(event.target.value)} onKeyDown={(event) => event.key === 'Enter' ? sendChat() : null} placeholder='Напиши сообщение...' maxLength='1000' />
|
||
<input className='hidden-file-input' ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
|
||
<button className='icon-btn attachment-btn' type='button' onClick={() => fileRef.current?.click()} aria-label='Прикрепить файл'>
|
||
<img className='control-icon' src={ICONS.attachment} alt='' />
|
||
</button>
|
||
<button className='btn primary' type='button' onClick={sendChat}>Отправить</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div className='call-bottom-actions is-visible'>
|
||
<button className='btn secondary' type='button' onClick={leaveCall}>Выйти</button>
|
||
<button className='btn danger' type='button' onClick={finishCall}>Завершить</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
|
||
function App() {
|
||
const [route, setRoute] = useState(routeFromLocation);
|
||
const [toasts, showToast] = useToasts();
|
||
|
||
useEffect(() => {
|
||
const onPop = () => setRoute(routeFromLocation());
|
||
window.addEventListener('popstate', onPop);
|
||
return () => window.removeEventListener('popstate', onPop);
|
||
}, []);
|
||
|
||
return (
|
||
<>
|
||
<Toasts items={toasts} />
|
||
{route.name === 'home' ? <HomePage showToast={showToast} /> : null}
|
||
{route.name === 'call' ? <CallPage roomName={route.roomName} showToast={showToast} /> : null}
|
||
{route.name === 'error' ? <ErrorPage code={route.code} /> : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
|
||
createRoot(document.getElementById('root')).render(<App />);
|