feat: updated
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<meta charset='UTF-8' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, viewport-fit=cover' />
|
||||
<meta name='description' content='Linkra: видеозвонки по ссылке в браузере.' />
|
||||
<link rel='icon' type='image/png' href='/icons/favicon.png' />
|
||||
<link rel='apple-touch-icon' href='/icons/favicon.png' />
|
||||
<title>Linkra</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
23
frontend/package-lock.json
generated
23
frontend/package-lock.json
generated
@@ -21,29 +21,6 @@
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
|
||||
BIN
frontend/public/icons/attachment.png
Normal file
BIN
frontend/public/icons/attachment.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
frontend/public/icons/camera_off.png
Normal file
BIN
frontend/public/icons/camera_off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/icons/camera_on.png
Normal file
BIN
frontend/public/icons/camera_on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/public/icons/favicon.png
Normal file
BIN
frontend/public/icons/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
BIN
frontend/public/icons/micro_off.png
Normal file
BIN
frontend/public/icons/micro_off.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/public/icons/micro_on.png
Normal file
BIN
frontend/public/icons/micro_on.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -5,7 +5,13 @@ import QRCode from'qrcode';
|
||||
import'./styles.css';
|
||||
|
||||
|
||||
const TELEGRAM_PREF_KEY = 'linkra-pref-telegram-alert';
|
||||
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) {
|
||||
@@ -103,9 +109,6 @@ function ErrorPage({ code }) {
|
||||
|
||||
|
||||
function HomePage({ showToast }) {
|
||||
const [config, setConfig] = useState({ telegram_alerting_available: false, max_attachment_size_mb: 100 });
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [telegramAlert, setTelegramAlert] = useState(() => localStorage.getItem(TELEGRAM_PREF_KEY) === '1');
|
||||
const [roomTitle, setRoomTitle] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -115,13 +118,8 @@ function HomePage({ showToast }) {
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Созвон по ссылке · Linkra';
|
||||
fetch(apiUrl('/api/config')).then(readJson).then(setConfig).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(TELEGRAM_PREF_KEY, telegramAlert ? '1' : '0');
|
||||
}, [telegramAlert]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!result || !qrRef.current) return;
|
||||
const target = result.short_invite_link || result.invite_link;
|
||||
@@ -149,7 +147,6 @@ function HomePage({ showToast }) {
|
||||
body: JSON.stringify({
|
||||
room_title: title,
|
||||
password: roomPassword,
|
||||
telegram_alert_enabled: config.telegram_alerting_available && telegramAlert,
|
||||
quick_join: quickJoin
|
||||
})
|
||||
});
|
||||
@@ -205,19 +202,6 @@ function HomePage({ showToast }) {
|
||||
<section className='card home-card'>
|
||||
<div className='home-card-header'>
|
||||
<h2>Новая комната</h2>
|
||||
<div className='settings-wrap'>
|
||||
<button className='icon-btn small' type='button' onClick={() => setSettingsOpen((value) => !value)}>⚙</button>
|
||||
{settingsOpen ? (
|
||||
<div className='settings-menu'>
|
||||
{config.telegram_alerting_available ? (
|
||||
<label className='checkbox-line'>
|
||||
<input type='checkbox' checked={telegramAlert} onChange={(event) => setTelegramAlert(event.target.checked)} />
|
||||
<span>Итоги созвона в Telegram</span>
|
||||
</label>
|
||||
) : <p className='muted'>Итоги в Telegram недоступны на сервере</p>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
@@ -262,7 +246,7 @@ function HomePage({ showToast }) {
|
||||
}
|
||||
|
||||
|
||||
function MediaTrack({ track, kind, muted = false, className = '' }) {
|
||||
function MediaTrack({ track, muted = false, className = '' }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -273,16 +257,49 @@ function MediaTrack({ track, kind, muted = false, className = '' }) {
|
||||
};
|
||||
}, [track]);
|
||||
|
||||
if (kind === 'audio') return <audio ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
||||
return <video ref={ref} autoPlay playsInline muted={muted} className={className} />;
|
||||
}
|
||||
|
||||
|
||||
function VideoTile({ item, active }) {
|
||||
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' : ''}`}>
|
||||
@@ -290,14 +307,28 @@ function VideoTile({ item, active }) {
|
||||
<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} kind='video' className='screen-share-video' muted={item.isLocal} />
|
||||
<MediaTrack track={screen.track} className='screen-share-video' muted={item.isLocal} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className='media-slot'>
|
||||
{camera ? <MediaTrack track={camera.track} kind='video' className={item.isLocal ? 'local-video' : ''} muted={item.isLocal} /> : <div className='avatar-placeholder'>{initial}</div>}
|
||||
{audio.map((entry) => item.isLocal ? null : <MediaTrack key={entry.id} track={entry.track} kind='audio' className='hidden-audio' />)}
|
||||
{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>
|
||||
);
|
||||
@@ -314,10 +345,11 @@ function CallPage({ roomName, showToast }) {
|
||||
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 });
|
||||
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);
|
||||
@@ -431,6 +463,14 @@ function CallPage({ roomName, showToast }) {
|
||||
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) {
|
||||
@@ -493,7 +533,13 @@ function CallPage({ roomName, showToast }) {
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
});
|
||||
await nextRoom.connect(data.server_url, data.participant_token);
|
||||
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);
|
||||
@@ -647,8 +693,14 @@ function CallPage({ roomName, showToast }) {
|
||||
</label>
|
||||
) : null}
|
||||
<div className='row'>
|
||||
<button className={`toggle-btn ${micEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleMic}>{micEnabled ? 'Микрофон включён' : 'Микрофон выключен'}</button>
|
||||
<button className={`toggle-btn ${cameraEnabled ? 'active' : 'inactive'}`} type='button' onClick={toggleCamera}>{cameraEnabled ? 'Камера включена' : 'Камера выключена'}</button>
|
||||
<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>
|
||||
@@ -664,15 +716,27 @@ function CallPage({ roomName, showToast }) {
|
||||
<p className='muted'>В комнате: {participants.length}</p>
|
||||
</div>
|
||||
<div className='meeting-controls'>
|
||||
<button className={`icon-btn ${micEnabled ? '' : 'is-off'}`} type='button' onClick={toggleMic}>Mic</button>
|
||||
<button className={`icon-btn ${cameraEnabled ? '' : 'is-off'}`} type='button' onClick={toggleCamera}>Cam</button>
|
||||
<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 key={item.identity} item={item} active={activeSpeakers.includes(item.identity)} />)}
|
||||
{participants.map((item) => (
|
||||
<VideoTile
|
||||
active={activeSpeakers.includes(item.identity)}
|
||||
item={item}
|
||||
key={item.identity}
|
||||
onVolumeChange={updateParticipantVolume}
|
||||
volume={participantVolumes[item.identity] ?? 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -690,7 +754,10 @@ function CallPage({ roomName, showToast }) {
|
||||
</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 ref={fileRef} type='file' onChange={(event) => sendFile(event.target.files?.[0])} />
|
||||
<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>
|
||||
|
||||
@@ -138,6 +138,11 @@ input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
padding: 0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.btn,
|
||||
.icon-btn,
|
||||
.toggle-btn {
|
||||
@@ -180,6 +185,9 @@ input {
|
||||
min-height: 50px;
|
||||
background: #1f2937;
|
||||
color: white;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn.small {
|
||||
@@ -205,6 +213,25 @@ input {
|
||||
border: 1px solid #4b5563;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.control-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.icon-btn .control-icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.home-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
@@ -382,6 +409,16 @@ input {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.participant-volume {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.screen-slot {
|
||||
height: 260px;
|
||||
background: #020617;
|
||||
@@ -473,10 +510,24 @@ input {
|
||||
|
||||
.chat-form {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-form input[type='text'] {
|
||||
min-width: 220px;
|
||||
flex: 1 1 260px;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-btn {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user