From 65d5f5fee9ca2a40d81886aee179bf5186dd3858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Sun, 5 Apr 2026 23:41:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B2=D0=BE=D0=BD=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Application/DTOs/SystemSettingsDto.cs | 3 +- backend/src/Host/Program.cs | 4 +- .../Commands/UpdateSettingsCommand.cs | 12 + .../WebRtc/Queries/GetIceServersQuery.cs | 12 +- .../presentation/components/ui/Avatar.tsx | 23 +- client-web/src/core/utils/utils.ts | 2 +- .../admin/presentation/pages/AdminPage.tsx | 4 +- .../presentation/components/CallModal.tsx | 363 +++++++++++------- .../components/GroupCallModal.tsx | 117 ++++-- .../presentation/components/ChatView.tsx | 26 +- .../presentation/components/GroupSettings.tsx | 46 +-- .../presentation/components/UserProfile.tsx | 16 +- 12 files changed, 400 insertions(+), 228 deletions(-) diff --git a/backend/src/Contracts/Settings/Application/DTOs/SystemSettingsDto.cs b/backend/src/Contracts/Settings/Application/DTOs/SystemSettingsDto.cs index 8cb4388..0425619 100644 --- a/backend/src/Contracts/Settings/Application/DTOs/SystemSettingsDto.cs +++ b/backend/src/Contracts/Settings/Application/DTOs/SystemSettingsDto.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Knot.Contracts.Settings.Application.DTOs; @@ -53,6 +53,7 @@ public class MessagesConfig public class WebRtcConfig { public bool Enabled { get; set; } = false; + public bool EnableVoiceCalls { get; set; } = true; public bool EnableVideoCalls { get; set; } = true; public bool EnableScreenSharing { get; set; } = true; public string TurnHost { get; set; } = string.Empty; diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 8cedf06..f59c0ea 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -80,6 +80,7 @@ builder.Services.AddStorageModule(builder.Configuration); builder.Services.AddStoriesModule(builder.Configuration); builder.Services.AddKlipyModule(); builder.Services.AddAdminModule(); +builder.Services.AddWebRtcModule(); builder.Services.AddSharedInfrastructure(builder.Configuration); // CQRS / MediatR для команд в Host (например, AdminController) @@ -91,7 +92,8 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies( typeof(Knot.Modules.Conversations.DependencyInjection).Assembly, typeof(Knot.Modules.Stories.DependencyInjection).Assembly, typeof(Knot.Modules.Klipy.DependencyInjection).Assembly, - typeof(Knot.Modules.Relations.DependencyInjection).Assembly + typeof(Knot.Modules.Relations.DependencyInjection).Assembly, + typeof(Knot.Modules.WebRtc.DependencyInjection).Assembly )); // Настройка CORS diff --git a/backend/src/Modules/Settings/Application/Settings/Commands/UpdateSettingsCommand.cs b/backend/src/Modules/Settings/Application/Settings/Commands/UpdateSettingsCommand.cs index 6dd029a..d97162c 100644 --- a/backend/src/Modules/Settings/Application/Settings/Commands/UpdateSettingsCommand.cs +++ b/backend/src/Modules/Settings/Application/Settings/Commands/UpdateSettingsCommand.cs @@ -24,6 +24,18 @@ internal sealed class UpdateSettingsCommandHandler : ICommandHandler> Handle(UpdateSettingsCommand request, CancellationToken cancellationToken) { + // Enforce WebRTC hierarchy + if (!request.Settings.WebRtc.Enabled) + { + request.Settings.WebRtc.EnableVideoCalls = false; + request.Settings.WebRtc.EnableScreenSharing = false; + } + else + { + // Master switch always means standard voice calls are available + request.Settings.WebRtc.EnableVoiceCalls = true; + } + if (request.Settings.Federation.Enabled && (string.IsNullOrEmpty(request.Settings.Federation.PrivateKey) || string.IsNullOrEmpty(request.Settings.Federation.PublicKey))) diff --git a/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs b/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs index 8a795b8..b729e12 100644 --- a/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs +++ b/backend/src/Modules/WebRtc/Application/WebRtc/Queries/GetIceServersQuery.cs @@ -5,6 +5,7 @@ using MediatR; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Knot.Modules.WebRtc.Application.WebRtc.Queries; @@ -16,10 +17,12 @@ public record GetIceServersQuery : IQuery; internal sealed class GetIceServersQueryHandler : IQueryHandler { private readonly ISettingsService _settingsService; + private readonly ILogger _logger; - public GetIceServersQueryHandler(ISettingsService settingsService) + public GetIceServersQueryHandler(ISettingsService settingsService, ILogger logger) { _settingsService = settingsService; + _logger = logger; } public async Task> Handle(GetIceServersQuery request, CancellationToken cancellationToken) @@ -27,8 +30,12 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler(new Error( "WebRtc.Disabled", "WebRTC calls are disabled by the server administrator.")); @@ -48,6 +55,7 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler +
{src ? ( {name} ) : (
{initials}
diff --git a/client-web/src/core/utils/utils.ts b/client-web/src/core/utils/utils.ts index 8aa7a93..5973c0b 100644 --- a/client-web/src/core/utils/utils.ts +++ b/client-web/src/core/utils/utils.ts @@ -70,7 +70,7 @@ export function getInitials(name: string): string { } export function generateAvatarColor(_name: string): string { - return 'bg-gradient-to-br from-[#3096e5] to-[#1e6fb3]'; + return 'bg-gradient-to-br from-primary to-primary-container'; } // Waveform cache so we don't decode the same audio twice diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index 24f1d48..8f5af84 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -287,7 +287,7 @@ const translations = { maxFileSize: 'Max size for message attachments.', noCopy: 'Prevent text copying in clients.', links: 'Make URLs clickable.', - webRtc: 'Enable real-time audio and video calls. This is a master switch for the WebRTC module.', + webRtc: 'Enable WebRTC module. This allows voice calls and voice conferences. Video calls and screen sharing are configured separately.', turn: 'Required for calls behind NAT.', klipy: 'Integration for stickers and GIFs.', federation: 'Communication between different Knot instances.', @@ -457,7 +457,7 @@ const translations = { maxFileSize: 'Лимит файлов в сообщениях.', noCopy: 'Мешать копированию текста.', links: 'Автоматические ссылки.', - webRtc: 'Включить возможность аудио и видео звонков. Глобальный переключатель для модуля WebRTC.', + webRtc: 'Включить модуль WebRTC. Это позволяет совершать аудиозвонки и аудиоконференции. Видеозвонки и трансляция экрана настраиваются отдельно.', turn: 'Нужно для звонков за NAT.', klipy: 'Стикеры и GIF.', federation: 'Связь с другими серверами Knot.', diff --git a/client-web/src/modules/calls/presentation/components/CallModal.tsx b/client-web/src/modules/calls/presentation/components/CallModal.tsx index 29fe9fc..f4d9f51 100644 --- a/client-web/src/modules/calls/presentation/components/CallModal.tsx +++ b/client-web/src/modules/calls/presentation/components/CallModal.tsx @@ -1,10 +1,13 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react'; +import Avatar from '../../../../core/presentation/components/ui/Avatar'; import { getSocket } from '../../../../core/infrastructure/socket'; import { UserApi } from '../../../users/infrastructure/userApi'; +import { useAuthStore } from '../../../auth/application/authStore'; import { useLang } from '../../../../core/infrastructure/i18n'; import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../../../../core/utils/sounds'; +import { getMediaUrl } from '../../../../core/utils/utils'; type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended'; @@ -108,13 +111,15 @@ function findVideoSender(pc: RTCPeerConnection): RTCRtpSender | undefined { export default function CallModal({ isOpen, onClose, targetUser, callType: initialCallType, incoming }: CallModalProps) { const { t } = useLang(); + const { config } = useAuthStore(); const [callState, setCallState] = useState('idle'); const [callType, setCallType] = useState<'voice' | 'video'>(initialCallType); const [isMuted, setIsMuted] = useState(false); - const [isVideoOff, setIsVideoOff] = useState(false); + const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice'); const [isScreenSharing, setIsScreenSharing] = useState(false); const [remoteScreenSharing, setRemoteScreenSharing] = useState(false); const [remoteIsMuted, setRemoteIsMuted] = useState(false); + const [remoteIsVideoOffSignal, setRemoteIsVideoOffSignal] = useState(false); const [hasRemoteVideo, setHasRemoteVideo] = useState(false); const hasRemoteVideoRef = useRef(false); const [isFullscreen, setIsFullscreen] = useState(false); @@ -255,14 +260,49 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi }, [hasRemoteVideo, callState]); // Setup common peer connection event handlers + const checkVideo = useCallback(() => { + const stream = remoteStreamRef.current; + if (!stream) { + setHasRemoteVideo(false); + return; + } + const videoTracks = stream.getVideoTracks(); + // Use !t.muted to detect if remote is actually sending frames. + const hasVideo = videoTracks.length > 0 && videoTracks.some( + t => t.readyState === 'live' && !t.muted + ) && !remoteIsVideoOffSignal; + + if (hasVideo !== hasRemoteVideo) { + hasRemoteVideoRef.current = hasVideo; + setHasRemoteVideo(hasVideo); + } + + // Auto-switch UI mode + // If we have video but callType is voice - expand + // BUT only if the remote hasn't explicitly told us their video is off (trust SignalR over phantom tracks) + if (hasVideo && callType === 'voice' && !remoteScreenSharing && !remoteIsVideoOffSignal) { + setCallType('video'); + } + // If we NO LONGER have video but callType is video - shrink + else if (!hasVideo && callType === 'video' && !remoteScreenSharing && !isScreenSharing) { + setCallType('voice'); + } + }, [hasRemoteVideo, callType, remoteScreenSharing, isScreenSharing]); + const setupPeerHandlers = useCallback((pc: RTCPeerConnection) => { pc.onicecandidate = (e) => { if (e.candidate) { + // Detailed log of candidate types to debug NAT traversal + const type = e.candidate.type || (e.candidate.candidate.includes('typ relay') ? 'relay' : e.candidate.candidate.includes('typ srflx') ? 'srflx' : 'host'); + console.log(`[WebRTC] Local candidate: ${type} - ${e.candidate.candidate.split(' ')[4]}:${e.candidate.candidate.split(' ')[5]}`); + const socket = getSocket(); socket?.emit('ice_candidate', { targetUserId: targetUserIdRef.current, candidate: e.candidate, }); + } else { + console.log('[WebRTC] Local candidate gathering finished'); } }; @@ -287,40 +327,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi const stream = remoteStreamRef.current; - // Helper to update video UI state - const checkVideo = () => { - const stream = remoteStreamRef.current; - if (!stream) { - setHasRemoteVideo(false); - return; - } - const videoTracks = stream.getVideoTracks(); - // Be more lenient: if track is 'live', trust it even if it reports 'muted' initially. - // Some browsers report muted while the buffer is filling. - const hasVideo = videoTracks.length > 0 && videoTracks.some( - t => t.readyState === 'live' && t.enabled - ); - - console.log('[checkVideo] remote videoTracks:', videoTracks.map(t => - `${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}` - ), '→ hasRemoteVideo:', hasVideo); - - if (hasVideo !== hasRemoteVideoRef.current) { - console.log(`[checkVideo] hasRemoteVideo changed: ${hasRemoteVideoRef.current} -> ${hasVideo}`); - hasRemoteVideoRef.current = hasVideo; - setHasRemoteVideo(hasVideo); - } - - // Auto-switch UI mode if remote starts sending video or STOPS sending it - if (hasVideo && callType === 'voice') { - console.log('[checkVideo] Remote started video, switching UI to video mode'); - setCallType('video'); - } else if (!hasVideo && callType === 'video') { - // If we were in video mode but tracks are gone, revert to voice - console.log('[checkVideo] Remote stopped video, reverting UI to voice mode'); - setCallType('voice'); - } - }; // Force binding to the SINGLE stable video element @@ -980,26 +986,56 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi const pc = peerRef.current; if (!pc) return; + const socket = getSocket(); + if (!isVideoOff) { // Turn off: disable video track on sender and revert to voice const sender = findVideoSender(pc); if (sender?.track) { sender.track.enabled = false; + sender.track.stop(); // Completely stop to trigger onmute/onended on remote + } + if (localStreamRef.current) { + localStreamRef.current.getVideoTracks().forEach(t => { + t.enabled = false; + t.stop(); + localStreamRef.current?.removeTrack(t); + }); } setIsVideoOff(true); setCallType('voice'); - const socket = getSocket(); - socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' }); + socket?.emit('call_type_changed', { + targetUserId: targetUserIdRef.current, + callType: 'voice', + isVideoOff: true + }); + socket?.emit('call_status', { + targetUserId: targetUserIdRef.current, + isMuted: isMuted, + isVideoOff: true + }); } else { - // Turn on: re-enable existing track or get new camera + // Turn on: re-enable existing track OR get new camera if none/ended const sender = findVideoSender(pc); - if (sender?.track) { + if (sender?.track && sender.track.readyState !== 'ended') { sender.track.enabled = true; setIsVideoOff(false); + setCallType('video'); + socket?.emit('call_type_changed', { + targetUserId: targetUserIdRef.current, + callType: 'video', + isVideoOff: false + }); + socket?.emit('call_status', { + targetUserId: targetUserIdRef.current, + isMuted: isMuted, + isVideoOff: false + }); + setRemoteIsVideoOffSignal(false); } else { try { - const { stream: camStream, hasVideo } = await getMediaWithCameraFallback(true); - if (!hasVideo) { + const { stream: camStream, hasVideo: trackFound } = await getMediaWithCameraFallback(true); + if (!trackFound) { console.warn('No camera available'); return; } @@ -1015,7 +1051,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi transceiver.direction = 'sendrecv'; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); - const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, @@ -1026,7 +1061,6 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi // Renegotiate since new track added const offer = await pc.createOffer(); await pc.setLocalDescription(offer); - const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, @@ -1039,14 +1073,23 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi setIsVideoOff(false); setCallType('video'); - const socket = getSocket(); - socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' }); + setRemoteIsVideoOffSignal(false); + socket?.emit('call_type_changed', { + targetUserId: targetUserIdRef.current, + callType: 'video', + isVideoOff: false + }); + socket?.emit('call_status', { + targetUserId: targetUserIdRef.current, + isMuted: isMuted, + isVideoOff: false + }); } catch (err) { console.error('Could not start camera:', err); } } } - }, [isVideoOff]); + }, [isVideoOff, isMuted, callType]); // Screen sharing const toggleScreenShare = useCallback(async () => { @@ -1141,9 +1184,9 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi }); } setCallType('voice'); - setIsVideoOff(false); + setIsVideoOff(true); const socket = getSocket(); - socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false }); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false, isVideoOff: true }); } setIsScreenSharing(false); @@ -1271,17 +1314,24 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi }); } setCallType('voice'); + setIsVideoOff(true); const socket = getSocket(); - socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false }); + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false, isVideoOff: true }); } }; setIsScreenSharing(true); setCallType('video'); + setIsVideoOff(false); const socket = getSocket(); - socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true }); - } catch (err) { + socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true, isVideoOff: false }); + } catch (err: any) { console.error('Error starting screen share:', err); + setIsScreenSharing(false); + if (!hadVideoBeforeScreenShareRef.current) { + setCallType('voice'); + setIsVideoOff(true); + } } } }, [isScreenSharing, activeCameraId, callType, isVideoOff]); @@ -1388,17 +1438,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi // Nudge video check after renegotiation setTimeout(() => { console.log('[onRenegotiate] Nudging video check'); - // pc.ontrack should have fired by now, calling checkVideo via setHasRemoteVideo - // but we can trigger it manually if we find the stream - if (remoteStreamRef.current) { - const hasVideo = remoteStreamRef.current.getVideoTracks().some(t => t.readyState === 'live'); - console.log('[onRenegotiate] Manually found video tracks:', hasVideo); - if (hasVideo && !hasRemoteVideoRef.current) { - hasRemoteVideoRef.current = true; - setHasRemoteVideo(true); - setCallType('video'); - } - } + checkVideo(); }, 1000); } catch (err) { console.error('Renegotiation error:', err); @@ -1414,36 +1454,60 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi } }; - const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: boolean }) => { + const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: any; isVideoOff?: any }) => { if (data.from !== targetUserIdRef.current) return; - console.log('[onCallTypeChanged] New call type:', data.callType, 'isRemoteScreenSharing:', data.isScreenSharing); - setCallType(data.callType); - setRemoteScreenSharing(!!data.isScreenSharing); - // If remote switched to voice, clear their video flag so we collapse the UI - if (data.callType === 'voice') { + const isRemoteSharing = data.isScreenSharing === true || data.isScreenSharing === 'True' || data.isScreenSharing === 'true'; + const isRemoteVideoOff = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true'; + + console.log('[onCallTypeChanged] New call type:', data.callType, 'isRemoteScreenSharing:', isRemoteSharing); + + setCallType(data.callType); + setRemoteScreenSharing(isRemoteSharing); + + if (data.isVideoOff !== undefined) { + setRemoteIsVideoOffSignal(isRemoteVideoOff); + if (isRemoteVideoOff) { + hasRemoteVideoRef.current = false; + setHasRemoteVideo(false); + } + } + + // If remote switched to voice WITHOUT screen sharing, clear their video flag + if (data.callType === 'voice' && !isRemoteSharing) { hasRemoteVideoRef.current = false; setHasRemoteVideo(false); + setRemoteIsVideoOffSignal(true); } - // If remote switched to video, nudge the video element - if (data.callType === 'video' || data.callType === 'voice') { - setTimeout(() => { - if (remoteVideoRef.current && remoteStreamRef.current) { - console.log(`[onCallTypeChanged] Nudging playback for ${data.callType}`); - remoteVideoRef.current.srcObject = remoteStreamRef.current; - remoteVideoRef.current.play().catch(e => { - if (e.name === 'NotAllowedError') setNeedsInteraction(true); - console.warn('Auto-play failed:', e); - }); - } - }, 1000); - } + // Nudge video check + setTimeout(checkVideo, 500); + + // Handle playback + setTimeout(() => { + if (remoteVideoRef.current && remoteStreamRef.current) { + console.log(`[onCallTypeChanged] Nudging playback for ${data.callType}`); + remoteVideoRef.current.srcObject = remoteStreamRef.current; + remoteVideoRef.current.play().catch(e => { + if (e.name === 'NotAllowedError') setNeedsInteraction(true); + }); + } + }, 1000); }; - const onCallStatusUpdated = (data: { from: string; isMuted: boolean; isVideoOff: boolean }) => { + const onCallStatusUpdated = (data: { from: string; isMuted: any; isVideoOff: any }) => { if (data.from !== targetUserIdRef.current) return; - setRemoteIsMuted(data.isMuted); + const isMutedVal = data.isMuted === true || data.isMuted === 'True' || data.isMuted === 'true'; + const isVideoOffVal = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true'; + + setRemoteIsMuted(isMutedVal); + setRemoteIsVideoOffSignal(isVideoOffVal); + + if (isVideoOffVal) { + if (callType === 'video' && !isScreenSharing && !remoteScreenSharing) { + setCallType('voice'); + } + } }; socket.on('call_answered', onCallAnswered); @@ -1566,10 +1630,16 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi if (!isOpen) return null; - const showVideoArea = callState === 'connected' && (callType === 'video' || remoteScreenSharing || isScreenSharing) && (hasRemoteVideo || !isVideoOff || isScreenSharing || remoteScreenSharing); const hasLocalVideo = !!( localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing ); + const hasLocalVideoDisplay = hasLocalVideo && (callType === 'video' || isScreenSharing); + const showVideoArea = callState === 'connected' && ( + (hasRemoteVideo && !remoteIsVideoOffSignal) || + hasLocalVideoDisplay || + isScreenSharing || + remoteScreenSharing + ); return ( @@ -1723,34 +1793,43 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi ref={remoteVideoRef} autoPlay playsInline - className={`w-full h-full object-contain transition-opacity duration-300 ${hasRemoteVideo ? 'opacity-100' : 'opacity-0'}`} + className={`w-full h-full object-contain transition-opacity duration-300 ${(hasRemoteVideo && !remoteIsVideoOffSignal) ? 'opacity-100' : 'opacity-0'}`} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }} /> - {/* Placeholder shown ONLY when track is not yet ready */} - {!hasRemoteVideo && ( -
+ {/* Placeholder shown when remote video is not active */} + {(!hasRemoteVideo || remoteIsVideoOffSignal) && !remoteScreenSharing && ( +
-
- {displayAvatar ? ( - - ) : ( -
- {initials} -
- )} +
+ +
+ +
- {displayName} -
-
- - {isScreenSharing ? 'Вы транслируете экран...' : 'Ожидание потока...'} +
+ + {callState === 'connected' ? 'Камера собеседника выключена' : 'Ожидание подключения...'}
)} + {/* Show different placeholder for screen sharing if it hasn't loaded yet */} + {!hasRemoteVideo && remoteScreenSharing && ( +
+ + {displayName} + Трансляция экрана... +
+ )} + {/* Remote muted indicator for video call over remote stream */} {hasRemoteVideo && remoteIsMuted && (
@@ -1785,7 +1864,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi )} {/* Local video PIP (bottom-right) */} - {hasLocalVideo && ( + {hasLocalVideoDisplay && (
@@ -1848,13 +1927,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi )}
- {displayAvatar ? ( - - ) : ( -
- {initials} -
- )} +
@@ -1931,36 +2009,45 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
- {/* Camera toggle */} - + {/* Camera selector */} + {!isVideoOff && callType === 'video' && ( + + )} + + )} + {config?.webRtc?.enableScreenSharing && ( + - {/* Camera selector */} - - + title={remoteScreenSharing ? (t('remoteSharingScreen' as any) as string) : isScreenSharing ? t('stopScreenShare') : t('screenShare')} + > + {isScreenSharing ? : } + + )} {/* Volume control */}
{/* Camera toggle */} - - + {config?.webRtc?.enableVideoCalls && ( + + )} + {config?.webRtc?.enableScreenSharing && ( + + )} - + {config?.webRtc?.enableVideoCalls && ( + + )} )} @@ -985,7 +987,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal {/* Закреплённое сообщение */} {/* Active group call banner */} - {chat?.type === 'group' && config?.enableCalls && activeGroupCallParticipants.length > 0 && ( + {chat?.type === 'group' && config?.webRtc?.enabled && activeGroupCallParticipants.length > 0 && (