feat: update call layout controls
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user