diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 28965ea..c5ecf2c 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -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 ; } -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 ( -
+
{ + if (item.isLocal) return; + event.preventDefault(); + onToggleVolumeMenu(item.identity); + }} + >
{item.name} - {item.isLocal ? 'ты' : ''} +
+ {item.isLocal ? 'ты' : ''} + {!item.isLocal ? ( + + ) : null} + {screen ? ( + + ) : null} +
- {!item.isLocal ? ( - + {!item.isLocal && volumeMenuOpen ? ( +
+ +
) : null} {screen ? (
@@ -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 }) { ) : ( <> -
-
-
-
Созвон
-

{call.room_title}

-

В комнате: {participants.length}

-
-
- - - - -
{time}
-
-
-
- {participants.map((item) => ( - - ))} -
-
- - +
+ + + + + +
{time}
+
+
+
+ {participants.map((item) => ( + + ))} +
+ + + +
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 3182353..d55013c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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;