feat: update call layout controls

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 10:04:24 +03:00
parent 65c464ab67
commit 373e29d716
2 changed files with 240 additions and 77 deletions

View File

@@ -262,8 +262,22 @@ function MediaTrack({ track, muted = false, className = '' }) {
function RemoteAudioTrack({ track, volume }) {
const audioRef = useRef(null);
const gainRef = useRef(null);
useEffect(() => {
if (!track || !audioRef.current) return undefined;
track.attach(audioRef.current);
return () => {
track.detach(audioRef.current);
};
}, [track]);
useEffect(() => {
if (!audioRef.current) return;
audioRef.current.volume = Math.min(1, Math.max(0, volume));
}, [volume]);
useEffect(() => {
if (!track?.mediaStreamTrack) return undefined;
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
@@ -272,10 +286,10 @@ function RemoteAudioTrack({ track, volume }) {
const stream = new MediaStream([track.mediaStreamTrack]);
const source = context.createMediaStreamSource(stream);
const gain = context.createGain();
gain.gain.value = volume;
gain.gain.value = Math.max(0, volume - 1);
source.connect(gain);
gain.connect(context.destination);
gainRef.current = gain;
gainRef.current = { context, gain };
context.resume().catch(() => {});
return () => {
@@ -287,14 +301,14 @@ function RemoteAudioTrack({ track, volume }) {
}, [track]);
useEffect(() => {
if (gainRef.current) gainRef.current.gain.value = volume;
if (gainRef.current) gainRef.current.gain.gain.value = Math.max(0, volume - 1);
}, [volume]);
return null;
return <audio ref={audioRef} autoPlay playsInline className='hidden-audio'></audio>;
}
function VideoTile({ item, active, volume, onVolumeChange }) {
function VideoTile({ item, active, volume, volumeMenuOpen, onScreenFullscreen, onToggleVolumeMenu, 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');
@@ -302,24 +316,45 @@ function VideoTile({ item, active, volume, onVolumeChange }) {
const volumePercent = Math.round(volume * 100);
return (
<div className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}>
<div
className={`video-tile${active ? ' speaking' : ''}${screen ? ' has-screen' : ''}`}
onContextMenu={(event) => {
if (item.isLocal) return;
event.preventDefault();
onToggleVolumeMenu(item.identity);
}}
>
<div className='video-label-row'>
<span>{item.name}</span>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
<div className='tile-actions'>
<span className='muted'>{item.isLocal ? 'ты' : ''}</span>
{!item.isLocal ? (
<button className='tile-action-btn' type='button' onClick={() => onToggleVolumeMenu(item.identity)} aria-label={`Настройки участника ${item.name}`}>
</button>
) : null}
{screen ? (
<button className='tile-action-btn' type='button' onClick={(event) => onScreenFullscreen(event.currentTarget)} aria-label='Развернуть демонстрацию'>
</button>
) : null}
</div>
</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>
{!item.isLocal && volumeMenuOpen ? (
<div className='participant-volume-popover'>
<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>
</div>
) : null}
{screen ? (
<div className='screen-slot'>
@@ -346,6 +381,7 @@ function CallPage({ roomName, showToast }) {
const [room, setRoom] = useState(null);
const [participants, setParticipants] = useState([]);
const [participantVolumes, setParticipantVolumes] = useState({});
const [volumeMenuIdentity, setVolumeMenuIdentity] = useState('');
const [activeSpeakers, setActiveSpeakers] = useState([]);
const [messages, setMessages] = useState([]);
const [chatText, setChatText] = useState('');
@@ -353,6 +389,7 @@ function CallPage({ roomName, showToast }) {
const [elapsed, setElapsed] = useState(0);
const previewRef = useRef(null);
const previewStreamRef = useRef(null);
const meetingCardRef = useRef(null);
const fileRef = useRef(null);
const inviteToken = useMemo(getInviteToken, []);
@@ -471,6 +508,43 @@ function CallPage({ roomName, showToast }) {
}));
}, []);
const toggleParticipantVolumeMenu = useCallback((identity) => {
setVolumeMenuIdentity((current) => current === identity ? '' : identity);
}, []);
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement;
const requestFullscreen = async (element) => {
if (!element) return;
if (element.requestFullscreen) {
await element.requestFullscreen();
return;
}
if (element.webkitRequestFullscreen) element.webkitRequestFullscreen();
};
const exitFullscreen = async () => {
if (document.exitFullscreen) {
await document.exitFullscreen();
return;
}
if (document.webkitExitFullscreen) document.webkitExitFullscreen();
};
const toggleCallFullscreen = async () => {
if (fullscreenElement()) {
await exitFullscreen();
return;
}
await requestFullscreen(meetingCardRef.current);
};
const openScreenFullscreen = async (button) => {
const tile = button.closest('.video-tile');
const target = tile?.querySelector('.screen-slot video') || tile?.querySelector('.screen-slot');
await requestFullscreen(target);
};
const joinCall = async () => {
const name = displayName.trim();
if (!name) {
@@ -708,59 +782,65 @@ function CallPage({ roomName, showToast }) {
</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 className='call-layout'>
<section className='card meeting-card' ref={meetingCardRef}>
<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>
<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='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} aria-label='Демонстрация экрана'>Screen</button>
<button className='btn secondary' type='button' onClick={toggleCallFullscreen}>Во весь экран</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}
onScreenFullscreen={openScreenFullscreen}
onToggleVolumeMenu={toggleParticipantVolumeMenu}
onVolumeChange={updateParticipantVolume}
volume={participantVolumes[item.identity] ?? 1}
volumeMenuOpen={volumeMenuIdentity === item.identity}
/>
))}
</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>
<div className='call-bottom-actions is-visible'>
<button className='btn secondary' type='button' onClick={leaveCall}>Выйти</button>

View File

@@ -355,6 +355,13 @@ input[type='range'] {
margin-bottom: 20px;
}
.call-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(320px, 380px);
gap: 20px;
align-items: start;
}
.meeting-title {
font-size: 11px;
font-weight: 700;
@@ -390,6 +397,7 @@ input[type='range'] {
}
.video-tile {
position: relative;
background: #0b1220;
border: 1px solid var(--line);
border-radius: 18px;
@@ -406,19 +414,55 @@ input[type='range'] {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
}
.tile-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tile-action-btn {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 9px;
background: rgba(15, 23, 42, 0.8);
color: var(--text);
cursor: pointer;
}
.tile-action-btn:hover {
background: rgba(37, 99, 235, 0.25);
}
.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;
}
.participant-volume-popover {
position: absolute;
top: 48px;
right: 10px;
z-index: 20;
width: min(260px, calc(100% - 20px));
padding: 12px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(17, 24, 39, 0.98);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
}
.screen-slot {
height: 260px;
background: #020617;
@@ -469,12 +513,18 @@ input[type='range'] {
}
.chat-card {
position: sticky;
top: 20px;
min-height: calc(100vh - 64px);
margin-bottom: 96px;
display: flex;
flex-direction: column;
}
.chat-messages {
min-height: 260px;
max-height: 420px;
flex: 1 1 auto;
min-height: 360px;
max-height: calc(100vh - 260px);
overflow-y: auto;
border: 1px solid var(--line);
border-radius: 16px;
@@ -547,12 +597,45 @@ input[type='range'] {
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22);
}
.meeting-card:fullscreen {
width: 100vw;
height: 100vh;
overflow: auto;
border-radius: 0;
background: var(--bg-deep);
}
.meeting-card:fullscreen .videos {
min-height: calc(100vh - 140px);
align-content: start;
}
.screen-slot:fullscreen,
.screen-slot video:fullscreen {
width: 100vw;
height: 100vh;
background: #000;
}
.screen-slot:fullscreen video,
.screen-slot video:fullscreen {
width: 100%;
height: 100%;
object-fit: contain !important;
}
@media (max-width: 900px) {
.home-grid,
.call-layout,
.prejoin-layout {
grid-template-columns: 1fr;
}
.chat-card {
position: static;
min-height: auto;
}
.meeting-topbar {
align-items: flex-start;
flex-direction: column;