Звонки
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,18 @@ internal sealed class UpdateSettingsCommandHandler : ICommandHandler<UpdateSetti
|
||||
|
||||
public async Task<Result<SystemSettingsDto>> 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)))
|
||||
|
||||
@@ -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<IceServersResultDto>;
|
||||
internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQuery, IceServersResultDto>
|
||||
{
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly ILogger<GetIceServersQueryHandler> _logger;
|
||||
|
||||
public GetIceServersQueryHandler(ISettingsService settingsService)
|
||||
public GetIceServersQueryHandler(ISettingsService settingsService, ILogger<GetIceServersQueryHandler> logger)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result<IceServersResultDto>> Handle(GetIceServersQuery request, CancellationToken cancellationToken)
|
||||
@@ -27,8 +30,12 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQue
|
||||
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||
var webrtc = settings.WebRtc;
|
||||
|
||||
_logger.LogInformation("WebRTC Config: Enabled={Enabled}, TurnHost={TurnHost}, TurnPort={TurnPort}, HasUser={HasUser}",
|
||||
webrtc.Enabled, webrtc.TurnHost, webrtc.TurnPort, !string.IsNullOrEmpty(webrtc.TurnUser));
|
||||
|
||||
if (!webrtc.Enabled)
|
||||
{
|
||||
_logger.LogWarning("WebRTC is disabled in server settings. Returning Failure.");
|
||||
return Result.Failure<IceServersResultDto>(new Error(
|
||||
"WebRtc.Disabled",
|
||||
"WebRTC calls are disabled by the server administrator."));
|
||||
@@ -48,6 +55,7 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQue
|
||||
// Добавляем TURN (TCP и UDP варианты)
|
||||
if (!string.IsNullOrEmpty(webrtc.TurnUser))
|
||||
{
|
||||
_logger.LogInformation("Creating TURN server entry with user {TurnUser}", webrtc.TurnUser);
|
||||
iceServers.Add(new IceServerDto(
|
||||
new[] { turnUrl, $"{turnUrl}?transport=tcp" },
|
||||
webrtc.TurnUser,
|
||||
@@ -57,10 +65,12 @@ internal sealed class GetIceServersQueryHandler : IQueryHandler<GetIceServersQue
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Using public Google STUN fallback because TurnHost is not configured.");
|
||||
// Если своего сервера нет, используем публичный Google STUN как fallback
|
||||
iceServers.Add(new IceServerDto(new[] { "stun:stun.l.google.com:19302" }));
|
||||
}
|
||||
|
||||
_logger.LogInformation("WebRTC: successfully gathered {Count} ICE servers", iceServers.Count);
|
||||
return Result.Success(new IceServersResultDto(iceServers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getInitials, generateAvatarColor } from '../../../utils/utils';
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
|
||||
className?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ const sizeClasses = {
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-xl',
|
||||
'2xl': 'w-32 h-32 text-4xl',
|
||||
'3xl': 'w-40 h-40 text-5xl',
|
||||
} as const;
|
||||
|
||||
const onlineDotSize = {
|
||||
@@ -23,24 +25,37 @@ const onlineDotSize = {
|
||||
md: 'w-2.5 h-2.5 border-2',
|
||||
lg: 'w-3 h-3 border-2',
|
||||
xl: 'w-4 h-4 border-2',
|
||||
'2xl': 'w-6 h-6 border-4',
|
||||
'3xl': 'w-8 h-8 border-4',
|
||||
} as const;
|
||||
|
||||
const roundingClasses = {
|
||||
xs: 'rounded-lg',
|
||||
sm: 'rounded-xl',
|
||||
md: 'rounded-xl',
|
||||
lg: 'rounded-2xl',
|
||||
xl: 'rounded-3xl',
|
||||
'2xl': 'rounded-[1.5rem]',
|
||||
'3xl': 'rounded-[2rem]',
|
||||
} as const;
|
||||
|
||||
function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const rounding = roundingClasses[size];
|
||||
const initials = getInitials(name || '?');
|
||||
const gradientClass = generateAvatarColor(name || '');
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 ${className} rounded-2xl border-2 border-outline-variant/10 overflow-hidden`}>
|
||||
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} border-2 border-outline-variant/10 overflow-hidden shadow-inner`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={`${sizeClass} rounded-2xl object-cover`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeClass} rounded-2xl ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
|
||||
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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<CallState>('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 (
|
||||
<AnimatePresence>
|
||||
@@ -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 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900 z-10">
|
||||
{/* Placeholder shown when remote video is not active */}
|
||||
{(!hasRemoteVideo || remoteIsVideoOffSignal) && !remoteScreenSharing && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900/50 backdrop-blur-md z-10">
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 rounded-full bg-knot-500/20 animate-ping" />
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="" className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-knot-500/30" />
|
||||
) : (
|
||||
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-3xl sm:text-4xl font-black border-4 border-primary/30 shadow-2xl">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 rounded-full bg-knot-500/10 animate-pulse" />
|
||||
<Avatar
|
||||
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
||||
name={displayName || '?'}
|
||||
size="2xl"
|
||||
className="relative shadow-2xl border-4 border-white/10"
|
||||
/>
|
||||
<div className="absolute -bottom-2 -right-2 w-10 h-10 rounded-full bg-zinc-800 border-2 border-zinc-700 flex items-center justify-center text-red-400 shadow-xl z-20">
|
||||
<VideoOff size={20} />
|
||||
</div>
|
||||
</div>
|
||||
<VideoOff size={48} className="text-zinc-500 mb-2" />
|
||||
<span className="text-xl text-white font-medium mb-1">{displayName}</span>
|
||||
<div className="flex items-center gap-2 text-zinc-500">
|
||||
<div className="w-2 h-2 rounded-full bg-knot-500 animate-pulse" />
|
||||
<span className="text-sm animate-pulse">
|
||||
{isScreenSharing ? 'Вы транслируете экран...' : 'Ожидание потока...'}
|
||||
<div className="flex items-center gap-2 text-zinc-400">
|
||||
<span className="text-sm">
|
||||
{callState === 'connected' ? 'Камера собеседника выключена' : 'Ожидание подключения...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show different placeholder for screen sharing if it hasn't loaded yet */}
|
||||
{!hasRemoteVideo && remoteScreenSharing && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-zinc-900 z-10">
|
||||
<Monitor size={48} className="text-knot-500 mb-4 animate-pulse" />
|
||||
<span className="text-xl text-white font-medium mb-1">{displayName}</span>
|
||||
<span className="text-sm text-zinc-400">Трансляция экрана...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote muted indicator for video call over remote stream */}
|
||||
{hasRemoteVideo && remoteIsMuted && (
|
||||
<div className="absolute top-4 left-4 z-20 flex items-center gap-2 bg-black/60 backdrop-blur-sm px-3 py-1.5 rounded-full text-red-400">
|
||||
@@ -1785,7 +1864,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
)}
|
||||
|
||||
{/* Local video PIP (bottom-right) */}
|
||||
{hasLocalVideo && (
|
||||
{hasLocalVideoDisplay && (
|
||||
<div className="absolute bottom-4 right-4 w-28 sm:w-48 rounded-xl overflow-hidden border-2 border-white/20 shadow-lg bg-black z-20"
|
||||
style={{ aspectRatio: '16 / 9' }}
|
||||
>
|
||||
@@ -1848,13 +1927,12 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
</>
|
||||
)}
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl cursor-pointer">
|
||||
{displayAvatar ? (
|
||||
<img src={displayAvatar} alt="" className="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-inner" />
|
||||
) : (
|
||||
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-3xl sm:text-4xl shadow-2xl border-4 border-primary/20">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
src={displayAvatar ? getMediaUrl(displayAvatar) : null}
|
||||
name={displayName || '?'}
|
||||
size="2xl"
|
||||
className="relative shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1931,36 +2009,45 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
|
||||
<ChevronUp size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Camera toggle */}
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
|
||||
{config?.webRtc?.enableVideoCalls && (
|
||||
<>
|
||||
{/* Camera toggle */}
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'
|
||||
}`}
|
||||
title={isVideoOff ? "Включить камеру" : "Выключить камеру"}
|
||||
>
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
{/* Camera selector */}
|
||||
{!isVideoOff && callType === 'video' && (
|
||||
<button
|
||||
onClick={openCameraMenu}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||
title={t('switchCamera')}
|
||||
>
|
||||
<SwitchCamera size={18} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{config?.webRtc?.enableScreenSharing && (
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
disabled={remoteScreenSharing}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${
|
||||
remoteScreenSharing
|
||||
? 'bg-zinc-800 text-zinc-600 cursor-not-allowed hidden sm:flex'
|
||||
: isScreenSharing
|
||||
? 'bg-knot-500/30 text-knot-400'
|
||||
: 'bg-white/10 text-white hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
{/* Camera selector */}
|
||||
<button
|
||||
onClick={openCameraMenu}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||
title={t('switchCamera')}
|
||||
>
|
||||
<SwitchCamera size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
disabled={remoteScreenSharing}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${
|
||||
remoteScreenSharing
|
||||
? 'bg-zinc-800 text-zinc-600 cursor-not-allowed hidden sm:flex'
|
||||
: isScreenSharing
|
||||
? 'bg-knot-500/30 text-knot-400'
|
||||
: 'bg-white/10 text-white hover:bg-white/20'
|
||||
}`}
|
||||
title={remoteScreenSharing ? (t('remoteSharingScreen' as any) as string) : isScreenSharing ? t('stopScreenShare') : t('screenShare')}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
|
||||
</button>
|
||||
title={remoteScreenSharing ? (t('remoteSharingScreen' as any) as string) : isScreenSharing ? t('stopScreenShare') : t('screenShare')}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
|
||||
</button>
|
||||
)}
|
||||
{/* Volume control */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
||||
import { useChatStore } from '../../../chats/application/chatStore';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
import { getMediaUrl } from '../../../../core/utils/utils';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
@@ -58,6 +59,7 @@ async function getIceServers(): Promise<RTCConfiguration> {
|
||||
|
||||
export default function GroupCallModal({ isOpen, onClose, chatId, chatName, callType: initialCallType }: GroupCallModalProps) {
|
||||
const { t } = useLang();
|
||||
const { user, config } = useAuthStore();
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice');
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
@@ -153,8 +155,13 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
const type = (e.candidate as any).type || (e.candidate.candidate.includes('typ relay') ? 'relay' : e.candidate.candidate.includes('typ srflx') ? 'srflx' : 'host');
|
||||
console.log(`[GroupCall] Local candidate for ${targetUserId}: ${type} - ${e.candidate.candidate.split(' ')[4]}:${e.candidate.candidate.split(' ')[5]}`);
|
||||
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_ice_candidate', { chatId, targetUserId, candidate: e.candidate });
|
||||
} else {
|
||||
console.log(`[GroupCall] Local candidate gathering for ${targetUserId} finished`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -176,13 +183,20 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
|
||||
// Play audio through audio element
|
||||
const audioEl = remoteAudioRefs.current.get(targetUserId);
|
||||
if (audioEl && audioEl.srcObject !== remoteStream) {
|
||||
audioEl.srcObject = remoteStream;
|
||||
audioEl.volume = remoteVolume;
|
||||
audioEl.play().catch(() => {});
|
||||
// Play audio through audio element (dedicated per participant to ensure sound even if video is hidden/off)
|
||||
let audioEl = remoteAudioRefs.current.get(targetUserId);
|
||||
if (!audioEl) {
|
||||
audioEl = new Audio();
|
||||
audioEl.autoplay = true;
|
||||
remoteAudioRefs.current.set(targetUserId, audioEl);
|
||||
}
|
||||
|
||||
if (audioEl.srcObject !== remoteStream) {
|
||||
console.log(`[GroupCall] Binding audio for ${targetUserId}`);
|
||||
audioEl.srcObject = remoteStream;
|
||||
}
|
||||
audioEl.volume = remoteVolume;
|
||||
audioEl.play().catch(err => console.warn(`[GroupCall] Audio play failed for ${targetUserId}:`, err));
|
||||
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
@@ -693,12 +707,15 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
});
|
||||
};
|
||||
|
||||
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: boolean; isVideoOff: boolean }) => {
|
||||
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: any; isVideoOff: any }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const isMutedVal = data.isMuted === true || data.isMuted === 'True' || data.isMuted === 'true';
|
||||
const isVideoOffVal = data.isVideoOff === true || data.isVideoOff === 'True' || data.isVideoOff === 'true';
|
||||
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
const p = next.get(data.userId);
|
||||
if (p) next.set(data.userId, { ...p, isMuted: data.isMuted, isVideoOff: data.isVideoOff });
|
||||
if (p) next.set(data.userId, { ...p, isMuted: isMutedVal, isVideoOff: isVideoOffVal });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -916,10 +933,6 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
const theMaximized = mainId ? allParticipants.find(p => p.id === mainId) : null;
|
||||
|
||||
const renderParticipantBlock = (p: any, isMaximized = false) => {
|
||||
const initials = p.isSelf
|
||||
? (t('you')?.charAt(0).toUpperCase() || 'Я')
|
||||
: (p.displayName || p.username).split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
@@ -930,12 +943,46 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
style={!isMaximized && theMaximized ? { width: '200px', height: '112px' } : {}}
|
||||
>
|
||||
{p.isSelf ? (
|
||||
p.hasVideo ? <video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" /> : <div className="flex flex-col items-center"><div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
|
||||
p.hasVideo ? (
|
||||
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative mb-2">
|
||||
<Avatar
|
||||
src={p.avatar ? getMediaUrl(p.avatar) : null}
|
||||
name={t('you') || '?'}
|
||||
size="lg"
|
||||
className="shadow-xl"
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-zinc-400 z-10">
|
||||
<VideoOff size={12} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-400 uppercase tracking-wider font-medium">{t('you')}</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
p.hasVideo && (!p.isVideoOff || p.isSharingScreen) ? <video id={`video-${p.id}`} autoPlay playsInline muted ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" /> : <div className="flex flex-col items-center">{p.avatar ? <img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" /> : <div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>}{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
|
||||
p.hasVideo && (!p.isVideoOff || p.isSharingScreen) ? (
|
||||
<video id={`video-${p.id}`} autoPlay playsInline ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative mb-2">
|
||||
<Avatar
|
||||
src={p.avatar ? getMediaUrl(p.avatar) : null}
|
||||
name={p.displayName || p.username || '?'}
|
||||
size="lg"
|
||||
className="shadow-xl"
|
||||
/>
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700 flex items-center justify-center text-red-400/80 z-10">
|
||||
<VideoOff size={12} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-medium">Камера выключена</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
|
||||
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/40 backdrop-blur-md text-[10px] text-white truncate max-w-[80%] flex items-center gap-1 border border-white/5">
|
||||
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted && <MicOff size={10} className="text-red-400" />}
|
||||
</div>
|
||||
{isMaximized && p.hasVideo && (!p.isVideoOff || p.isSelf) && (
|
||||
<button
|
||||
@@ -1005,23 +1052,27 @@ export default function GroupCallModal({ isOpen, onClose, chatId, chatName, call
|
||||
</button>
|
||||
</div>
|
||||
{/* Camera toggle */}
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
>
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
disabled={!!sharingUserId && !isScreenSharing}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
|
||||
</button>
|
||||
{config?.webRtc?.enableVideoCalls && (
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
>
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
)}
|
||||
{config?.webRtc?.enableScreenSharing && (
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
disabled={!!sharingUserId && !isScreenSharing}
|
||||
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
|
||||
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
|
||||
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||
|
||||
@@ -812,17 +812,19 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('call')}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (chat.type === 'personal' && otherMember) {
|
||||
onStartCall?.(otherMember.user, 'video');
|
||||
} else if (chat.type === 'group') {
|
||||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
</button>
|
||||
{config?.webRtc?.enableVideoCalls && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (chat.type === 'personal' && otherMember) {
|
||||
onStartCall?.(otherMember.user, 'video');
|
||||
} else if (chat.type === 'group') {
|
||||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-xl hover:bg-surface-container-highest/40 transition-all duration-300 text-on-surface-variant hover:text-primary slide-on-ice" title={t('videoCall')}>
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
<button
|
||||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||||
|
||||
@@ -355,17 +355,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
<div className="relative group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-knot-500/20 rounded-full blur-[40px] pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||
{chat.avatar ? (
|
||||
<img
|
||||
src={getMediaUrl(chat.avatar)}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-4xl shadow-inner">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
src={chat.avatar ? getMediaUrl(chat.avatar) : null}
|
||||
name={chat.name || '?'}
|
||||
size="2xl"
|
||||
className="shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
@@ -574,13 +569,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
onClick={() => handleAddMember(u.id)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<Avatar
|
||||
src={u.avatar ? getMediaUrl(u.avatar) : null}
|
||||
name={u.displayName || u.username || '?'}
|
||||
size="sm"
|
||||
online={u.isOnline}
|
||||
/>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||||
@@ -613,16 +607,12 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{member.user.avatar ? (
|
||||
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
|
||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{member.user.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
<Avatar
|
||||
src={member.user.avatar ? getMediaUrl(member.user.avatar) : null}
|
||||
name={member.user.displayName || member.user.username || '?'}
|
||||
size="sm"
|
||||
online={member.user.isOnline}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -15,7 +15,8 @@ import { httpClient } from '../../../../core/infrastructure/httpClient';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
|
||||
import { getInitials } from '../../../../core/utils/utils';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
@@ -227,12 +228,13 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
<div className="flex flex-col items-center mb-10">
|
||||
<div className="relative mb-8 group">
|
||||
<div className="absolute -inset-4 bg-primary/20 blur-3xl rounded-[3rem] opacity-40 group-hover:opacity-60 transition-opacity" />
|
||||
<div className="relative w-40 h-40 rounded-[3rem] bg-zinc-900 border-2 border-white/10 overflow-hidden shadow-2xl">
|
||||
{user.avatar ? (
|
||||
<img src={resolveUrl(user.avatar)} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/30 to-primary-container/10 text-5xl font-black text-primary">{getInitials(user.displayName)}</div>
|
||||
)}
|
||||
<div className="relative w-40 h-40">
|
||||
<Avatar
|
||||
src={user.avatar ? resolveUrl(user.avatar) : null}
|
||||
name={user.displayName || '?'}
|
||||
size="3xl"
|
||||
className="shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-4xl font-black text-white mb-3 tracking-tighter text-center">{user.displayName}</h2>
|
||||
|
||||
Reference in New Issue
Block a user