SHA256
Добавить базовую поддержку видеозвонка
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.5.12
|
client.version=1.5.13
|
||||||
server.version=1.4.9
|
server.version=1.4.9
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000;
|
|||||||
const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000;
|
const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000;
|
||||||
const INCOMING_CONNECT_TIMEOUT_MS = 20_000;
|
const INCOMING_CONNECT_TIMEOUT_MS = 20_000;
|
||||||
const MAX_TIMELINE_ENTRIES = 64;
|
const MAX_TIMELINE_ENTRIES = 64;
|
||||||
|
const CAMERA_CONSTRAINTS = Object.freeze({
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 },
|
||||||
|
frameRate: { ideal: 24, max: 30 },
|
||||||
|
facingMode: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
function nowMs() {
|
function nowMs() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
@@ -624,12 +630,20 @@ function getCallStateSnapshot() {
|
|||||||
if (!call) return null;
|
if (!call) return null;
|
||||||
if (call.localUiDismissed) return null;
|
if (call.localUiDismissed) return null;
|
||||||
const callPhase = String(call.phase || '').trim();
|
const callPhase = String(call.phase || '').trim();
|
||||||
|
const localVideoTracks = call.localStream?.getVideoTracks?.() || [];
|
||||||
|
const remoteVideoTracks = call.remoteMediaStream?.getVideoTracks?.() || [];
|
||||||
const canToggleSpeaker = Boolean(
|
const canToggleSpeaker = Boolean(
|
||||||
callPhase !== 'incoming'
|
callPhase !== 'incoming'
|
||||||
&& callPhase !== 'ended'
|
&& callPhase !== 'ended'
|
||||||
&& canRouteAudioOutputInBrowser()
|
&& canRouteAudioOutputInBrowser()
|
||||||
&& call.remoteAudio
|
&& call.remoteAudio
|
||||||
);
|
);
|
||||||
|
const canToggleCamera = Boolean(
|
||||||
|
callPhase !== 'incoming'
|
||||||
|
&& callPhase !== 'ended'
|
||||||
|
&& navigator?.mediaDevices?.getUserMedia
|
||||||
|
&& call.pc
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
peerLogin: call.peerLogin || '',
|
peerLogin: call.peerLogin || '',
|
||||||
@@ -639,12 +653,18 @@ function getCallStateSnapshot() {
|
|||||||
startedAtMs: Number(call.startedAtMs || 0),
|
startedAtMs: Number(call.startedAtMs || 0),
|
||||||
connectedAtMs: Number(call.connectedAtMs || 0),
|
connectedAtMs: Number(call.connectedAtMs || 0),
|
||||||
muted: Boolean(call.muted),
|
muted: Boolean(call.muted),
|
||||||
|
cameraEnabled: Boolean(call.cameraEnabled),
|
||||||
speakerEnabled: Boolean(call.speakerEnabled),
|
speakerEnabled: Boolean(call.speakerEnabled),
|
||||||
canToggleSpeaker,
|
canToggleSpeaker,
|
||||||
|
canToggleCamera,
|
||||||
canAnswer: callPhase === 'incoming',
|
canAnswer: callPhase === 'incoming',
|
||||||
canDecline: callPhase === 'incoming',
|
canDecline: callPhase === 'incoming',
|
||||||
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
||||||
canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting',
|
canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting',
|
||||||
|
hasLocalVideo: localVideoTracks.some((track) => track?.readyState === 'live' && track?.enabled !== false),
|
||||||
|
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live'),
|
||||||
|
localPreviewStream: call.localStream || null,
|
||||||
|
remoteMediaStream: call.remoteMediaStream || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +734,7 @@ function buildCallFactsJson(call, extra = {}) {
|
|||||||
pcSignalingState: pc?.signalingState || '',
|
pcSignalingState: pc?.signalingState || '',
|
||||||
hasLocalStream: Boolean(call?.localStream),
|
hasLocalStream: Boolean(call?.localStream),
|
||||||
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
||||||
|
localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0,
|
||||||
timeline: buildTimelineSummary(call),
|
timeline: buildTimelineSummary(call),
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -744,6 +765,7 @@ function buildCallFactsLine(call, extra = {}) {
|
|||||||
pcSignalingState: pc?.signalingState || '',
|
pcSignalingState: pc?.signalingState || '',
|
||||||
hasLocalStream: Boolean(call?.localStream),
|
hasLocalStream: Boolean(call?.localStream),
|
||||||
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0,
|
||||||
|
localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0,
|
||||||
localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)),
|
localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)),
|
||||||
timeline: buildTimelineSummary(call),
|
timeline: buildTimelineSummary(call),
|
||||||
...extra,
|
...extra,
|
||||||
@@ -757,12 +779,13 @@ function buildMediaSnapshot(call) {
|
|||||||
const localStream = call?.localStream || null;
|
const localStream = call?.localStream || null;
|
||||||
const localTracks = localStream?.getTracks?.() || [];
|
const localTracks = localStream?.getTracks?.() || [];
|
||||||
const remoteAudio = call?.remoteAudio || null;
|
const remoteAudio = call?.remoteAudio || null;
|
||||||
const remoteStream = remoteAudio?.srcObject || null;
|
const remoteStream = call?.remoteMediaStream || remoteAudio?.srcObject || null;
|
||||||
const remoteTracks = remoteStream?.getTracks?.() || [];
|
const remoteTracks = remoteStream?.getTracks?.() || [];
|
||||||
return {
|
return {
|
||||||
hasLocalStream: Boolean(localStream),
|
hasLocalStream: Boolean(localStream),
|
||||||
localTrackCount: localTracks.length,
|
localTrackCount: localTracks.length,
|
||||||
localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0,
|
localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0,
|
||||||
|
localVideoTracksCount: localStream?.getVideoTracks?.()?.length || 0,
|
||||||
localTrackStates: localTracks.map((track) => ({
|
localTrackStates: localTracks.map((track) => ({
|
||||||
kind: String(track?.kind || ''),
|
kind: String(track?.kind || ''),
|
||||||
enabled: Boolean(track?.enabled),
|
enabled: Boolean(track?.enabled),
|
||||||
@@ -799,6 +822,7 @@ function summarizeMediaSnapshot(snapshot) {
|
|||||||
`localStream=${snapshot.hasLocalStream ? 1 : 0}`,
|
`localStream=${snapshot.hasLocalStream ? 1 : 0}`,
|
||||||
`localTracks=${Number(snapshot.localTrackCount || 0)}`,
|
`localTracks=${Number(snapshot.localTrackCount || 0)}`,
|
||||||
`localAudio=${Number(snapshot.localAudioTracksCount || 0)}`,
|
`localAudio=${Number(snapshot.localAudioTracksCount || 0)}`,
|
||||||
|
`localVideo=${Number(snapshot.localVideoTracksCount || 0)}`,
|
||||||
`localStates=${localSummary || '-'}`,
|
`localStates=${localSummary || '-'}`,
|
||||||
`remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`,
|
`remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`,
|
||||||
`remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`,
|
`remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`,
|
||||||
@@ -820,7 +844,9 @@ function getCallDiagnosticsContext(call) {
|
|||||||
: false;
|
: false;
|
||||||
const localTracks = call?.localStream?.getTracks?.() || [];
|
const localTracks = call?.localStream?.getTracks?.() || [];
|
||||||
const localAudioTracks = call?.localStream?.getAudioTracks?.() || [];
|
const localAudioTracks = call?.localStream?.getAudioTracks?.() || [];
|
||||||
|
const localVideoTracks = call?.localStream?.getVideoTracks?.() || [];
|
||||||
const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length;
|
const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length;
|
||||||
|
const enabledLocalVideoTracks = localVideoTracks.filter((track) => track?.enabled).length;
|
||||||
const transceiversCount = pc?.getTransceivers?.()?.length || 0;
|
const transceiversCount = pc?.getTransceivers?.()?.length || 0;
|
||||||
const sendersCount = pc?.getSenders?.()?.length || 0;
|
const sendersCount = pc?.getSenders?.()?.length || 0;
|
||||||
const receiversCount = pc?.getReceivers?.()?.length || 0;
|
const receiversCount = pc?.getReceivers?.()?.length || 0;
|
||||||
@@ -849,6 +875,9 @@ function getCallDiagnosticsContext(call) {
|
|||||||
localAudioTracksCount: localAudioTracks.length,
|
localAudioTracksCount: localAudioTracks.length,
|
||||||
localAudioTracksEnabledCount: enabledLocalAudioTracks,
|
localAudioTracksEnabledCount: enabledLocalAudioTracks,
|
||||||
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
|
localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'),
|
||||||
|
localVideoTracksCount: localVideoTracks.length,
|
||||||
|
localVideoTracksEnabledCount: enabledLocalVideoTracks,
|
||||||
|
localVideoTrackLabels: localVideoTracks.map((t) => String(t?.label || '')).join('|'),
|
||||||
localTrackStates: summarizeMediaSnapshot(mediaSnapshot),
|
localTrackStates: summarizeMediaSnapshot(mediaSnapshot),
|
||||||
hasPeerConnection: Boolean(pc),
|
hasPeerConnection: Boolean(pc),
|
||||||
pcConnectionState: pc?.connectionState || '',
|
pcConnectionState: pc?.connectionState || '',
|
||||||
@@ -985,7 +1014,7 @@ async function closeMedia(call) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
try {
|
try {
|
||||||
if (call.remoteAudio) {
|
if (call.remoteAudio) {
|
||||||
const remoteTracks = call.remoteAudio.srcObject?.getTracks?.() || [];
|
const remoteTracks = call.remoteMediaStream?.getTracks?.() || call.remoteAudio.srcObject?.getTracks?.() || [];
|
||||||
remoteTracks.forEach((track) => {
|
remoteTracks.forEach((track) => {
|
||||||
try { track.enabled = false; } catch {}
|
try { track.enabled = false; } catch {}
|
||||||
try { track.stop(); } catch {}
|
try { track.stop(); } catch {}
|
||||||
@@ -1000,6 +1029,9 @@ async function closeMedia(call) {
|
|||||||
try { pc?.close?.(); } catch {}
|
try { pc?.close?.(); } catch {}
|
||||||
call.pc = null;
|
call.pc = null;
|
||||||
call.localStream = null;
|
call.localStream = null;
|
||||||
|
call.localVideoTrack = null;
|
||||||
|
call.videoSender = null;
|
||||||
|
call.remoteMediaStream = null;
|
||||||
call.audioSenders = [];
|
call.audioSenders = [];
|
||||||
call.connectionRouteLabel = '';
|
call.connectionRouteLabel = '';
|
||||||
call.connectionRouteDetails = '';
|
call.connectionRouteDetails = '';
|
||||||
@@ -1510,6 +1542,7 @@ async function ensurePeerConnection(call) {
|
|||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
call.localStream = stream;
|
call.localStream = stream;
|
||||||
call.audioSenders = [];
|
call.audioSenders = [];
|
||||||
|
call.videoSender = null;
|
||||||
call.connectionRouteLabel = '';
|
call.connectionRouteLabel = '';
|
||||||
call.connectionRouteDetails = '';
|
call.connectionRouteDetails = '';
|
||||||
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
|
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
|
||||||
@@ -1528,12 +1561,31 @@ async function ensurePeerConnection(call) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pc.ontrack = (evt) => {
|
pc.ontrack = (evt) => {
|
||||||
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
|
const trackKind = String(evt?.track?.kind || '');
|
||||||
const audio = new Audio();
|
recordCallTimeline(call, 'remote_track_received', `kind=${trackKind || '-'}; streams=${evt?.streams?.length || 0}`);
|
||||||
audio.autoplay = true;
|
let remoteStream = call.remoteMediaStream || null;
|
||||||
audio.playsInline = true;
|
if (evt?.streams?.[0]) {
|
||||||
audio.srcObject = evt.streams[0];
|
remoteStream = evt.streams[0];
|
||||||
call.remoteAudio = audio;
|
} else {
|
||||||
|
if (!remoteStream) remoteStream = new MediaStream();
|
||||||
|
if (evt?.track && !remoteStream.getTracks().some((track) => track.id === evt.track.id)) {
|
||||||
|
remoteStream.addTrack(evt.track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
call.remoteMediaStream = remoteStream;
|
||||||
|
if (!call.remoteAudio) {
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.autoplay = true;
|
||||||
|
audio.playsInline = true;
|
||||||
|
call.remoteAudio = audio;
|
||||||
|
}
|
||||||
|
if (evt?.track) {
|
||||||
|
evt.track.onended = () => notifyCallState();
|
||||||
|
evt.track.onmute = () => notifyCallState();
|
||||||
|
evt.track.onunmute = () => notifyCallState();
|
||||||
|
}
|
||||||
|
call.remoteAudio.srcObject = remoteStream;
|
||||||
|
void call.remoteAudio.play?.().catch?.(() => {});
|
||||||
notifyCallState();
|
notifyCallState();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1544,6 +1596,30 @@ async function ensurePeerConnection(call) {
|
|||||||
return pc;
|
return pc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function renegotiateCall(call, reason = '') {
|
||||||
|
if (!call?.pc || !call.remoteSessionId) return false;
|
||||||
|
if (call.negotiationInProgress) {
|
||||||
|
recordCallTimeline(call, 'renegotiate_skipped_busy', `reason=${reason || '-'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
call.negotiationInProgress = true;
|
||||||
|
try {
|
||||||
|
const pc = call.pc;
|
||||||
|
if (pc.signalingState !== 'stable' && pc.localDescription?.type === 'offer') {
|
||||||
|
recordCallTimeline(call, 'renegotiate_wait_unstable', `state=${pc.signalingState}; reason=${reason || '-'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
recordCallTimeline(call, 'renegotiate_start', `reason=${reason || '-'}`);
|
||||||
|
const offer = await pc.createOffer();
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
||||||
|
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${reason || '-'}`);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
call.negotiationInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onAccept(call) {
|
async function onAccept(call) {
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
if (call.initialOfferInProgress || call.initialOfferSent) {
|
if (call.initialOfferInProgress || call.initialOfferSent) {
|
||||||
@@ -1654,8 +1730,12 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|||||||
connectedAtMs: 0,
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
localVideoTrack: null,
|
||||||
|
remoteMediaStream: null,
|
||||||
audioSenders: [],
|
audioSenders: [],
|
||||||
|
videoSender: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
cameraEnabled: false,
|
||||||
connectionRouteLabel: '',
|
connectionRouteLabel: '',
|
||||||
reconnectInProgress: false,
|
reconnectInProgress: false,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
@@ -1746,6 +1826,82 @@ async function applyMicState(call) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureCameraTrack(call) {
|
||||||
|
const existingTrack = call?.localVideoTrack;
|
||||||
|
if (existingTrack && existingTrack.readyState === 'live') {
|
||||||
|
existingTrack.enabled = true;
|
||||||
|
return existingTrack;
|
||||||
|
}
|
||||||
|
const videoStream = await navigator.mediaDevices.getUserMedia({ video: CAMERA_CONSTRAINTS, audio: false });
|
||||||
|
const videoTrack = videoStream.getVideoTracks?.()[0] || null;
|
||||||
|
if (!videoTrack) {
|
||||||
|
throw new Error('Не удалось получить видеотрек');
|
||||||
|
}
|
||||||
|
const streamTracks = videoStream.getTracks?.() || [];
|
||||||
|
streamTracks.forEach((track) => {
|
||||||
|
if (track !== videoTrack) {
|
||||||
|
try { track.stop(); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!call.localStream) {
|
||||||
|
call.localStream = new MediaStream();
|
||||||
|
}
|
||||||
|
const currentLocalVideoTracks = call.localStream.getVideoTracks?.() || [];
|
||||||
|
currentLocalVideoTracks.forEach((track) => {
|
||||||
|
if (track.id !== videoTrack.id) {
|
||||||
|
try { call.localStream.removeTrack(track); } catch {}
|
||||||
|
try { track.stop(); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
call.localStream.addTrack(videoTrack);
|
||||||
|
call.localVideoTrack = videoTrack;
|
||||||
|
return videoTrack;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableCameraTrack(call) {
|
||||||
|
const videoTrack = call?.localVideoTrack || null;
|
||||||
|
if (videoTrack) {
|
||||||
|
try { videoTrack.enabled = false; } catch {}
|
||||||
|
try { call.localStream?.removeTrack?.(videoTrack); } catch {}
|
||||||
|
try { videoTrack.stop(); } catch {}
|
||||||
|
}
|
||||||
|
call.localVideoTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCameraState(call, { renegotiate = true, reason = '' } = {}) {
|
||||||
|
if (!call?.pc) return false;
|
||||||
|
const pc = call.pc;
|
||||||
|
const transceivers = pc.getTransceivers?.() || [];
|
||||||
|
let videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
|
||||||
|
if (call.cameraEnabled) {
|
||||||
|
const videoTrack = await ensureCameraTrack(call);
|
||||||
|
if (!videoTransceiver) {
|
||||||
|
const sender = pc.addTrack(videoTrack, call.localStream);
|
||||||
|
call.videoSender = sender;
|
||||||
|
videoTransceiver = transceivers.find((tr) => tr?.sender === sender) || null;
|
||||||
|
} else {
|
||||||
|
await videoTransceiver.sender.replaceTrack(videoTrack);
|
||||||
|
call.videoSender = videoTransceiver.sender;
|
||||||
|
}
|
||||||
|
if (videoTransceiver) {
|
||||||
|
try { videoTransceiver.direction = 'sendrecv'; } catch {}
|
||||||
|
}
|
||||||
|
videoTrack.enabled = true;
|
||||||
|
} else {
|
||||||
|
if (videoTransceiver) {
|
||||||
|
try { await videoTransceiver.sender.replaceTrack(null); } catch {}
|
||||||
|
call.videoSender = videoTransceiver.sender;
|
||||||
|
try { videoTransceiver.direction = 'recvonly'; } catch {}
|
||||||
|
}
|
||||||
|
await disableCameraTrack(call);
|
||||||
|
}
|
||||||
|
notifyCallState();
|
||||||
|
if (renegotiate) {
|
||||||
|
await renegotiateCall(call, reason || (call.cameraEnabled ? 'camera_on' : 'camera_off'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export async function setMicMuted(muted) {
|
export async function setMicMuted(muted) {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call) return;
|
if (!call) return;
|
||||||
@@ -1760,6 +1916,29 @@ export async function toggleMicMuted() {
|
|||||||
await setMicMuted(!call.muted);
|
await setMicMuted(!call.muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setCameraEnabled(enabled) {
|
||||||
|
const call = getActiveCall();
|
||||||
|
if (!call || !call.pc) return false;
|
||||||
|
const nextEnabled = Boolean(enabled);
|
||||||
|
if (Boolean(call.cameraEnabled) === nextEnabled) return true;
|
||||||
|
call.cameraEnabled = nextEnabled;
|
||||||
|
try {
|
||||||
|
await applyCameraState(call, { renegotiate: true, reason: nextEnabled ? 'camera_on' : 'camera_off' });
|
||||||
|
notifyCallState();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
call.cameraEnabled = !nextEnabled;
|
||||||
|
notifyCallState();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleCameraEnabled() {
|
||||||
|
const call = getActiveCall();
|
||||||
|
if (!call) return false;
|
||||||
|
return setCameraEnabled(!call.cameraEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleSpeakerMode() {
|
export async function toggleSpeakerMode() {
|
||||||
const call = getActiveCall();
|
const call = getActiveCall();
|
||||||
if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false;
|
if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false;
|
||||||
@@ -1810,8 +1989,12 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|||||||
connectedAtMs: 0,
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
localVideoTrack: null,
|
||||||
|
remoteMediaStream: null,
|
||||||
audioSenders: [],
|
audioSenders: [],
|
||||||
|
videoSender: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
cameraEnabled: false,
|
||||||
connectionRouteLabel: '',
|
connectionRouteLabel: '',
|
||||||
debugMode: true,
|
debugMode: true,
|
||||||
debugRunId: String(runId || '').trim(),
|
debugRunId: String(runId || '').trim(),
|
||||||
@@ -1849,8 +2032,12 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|||||||
connectedAtMs: 0,
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
localVideoTrack: null,
|
||||||
|
remoteMediaStream: null,
|
||||||
audioSenders: [],
|
audioSenders: [],
|
||||||
|
videoSender: null,
|
||||||
muted: false,
|
muted: false,
|
||||||
|
cameraEnabled: false,
|
||||||
connectionRouteLabel: '',
|
connectionRouteLabel: '',
|
||||||
debugMode: true,
|
debugMode: true,
|
||||||
debugRunId: String(runId || '').trim(),
|
debugRunId: String(runId || '').trim(),
|
||||||
@@ -1902,11 +2089,15 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
connectedAtMs: 0,
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
audioSenders: [],
|
localVideoTrack: null,
|
||||||
muted: false,
|
remoteMediaStream: null,
|
||||||
connectionRouteLabel: '',
|
audioSenders: [],
|
||||||
|
videoSender: null,
|
||||||
|
muted: false,
|
||||||
|
cameraEnabled: false,
|
||||||
|
connectionRouteLabel: '',
|
||||||
reconnectInProgress: false,
|
reconnectInProgress: false,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
@@ -2149,12 +2340,19 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
try {
|
try {
|
||||||
recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`);
|
recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`);
|
||||||
const pc = await ensurePeerConnection(call);
|
const pc = await ensurePeerConnection(call);
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
const incomingOffer = new RTCSessionDescription(JSON.parse(data));
|
||||||
|
const localType = String(pc.localDescription?.type || '').trim().toLowerCase();
|
||||||
|
if (pc.signalingState !== 'stable' && localType === 'offer') {
|
||||||
|
recordCallTimeline(call, 'offer_glare_rollback', `state=${pc.signalingState}; from=${fromSessionId || '-'}`);
|
||||||
|
await pc.setLocalDescription({ type: 'rollback' });
|
||||||
|
}
|
||||||
|
await pc.setRemoteDescription(incomingOffer);
|
||||||
await flushPendingIceCandidates(call);
|
await flushPendingIceCandidates(call);
|
||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
||||||
setStatus(call, 'Соединяем…', 'connecting');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
|
notifyCallState();
|
||||||
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
|
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
|
||||||
@@ -2187,6 +2385,7 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
await flushPendingIceCandidates(call);
|
await flushPendingIceCandidates(call);
|
||||||
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
|
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
|
||||||
setStatus(call, 'Соединяем…', 'connecting');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
|
notifyCallState();
|
||||||
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
|
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
|
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
acceptIncomingCall,
|
acceptIncomingCall,
|
||||||
declineIncomingCall,
|
declineIncomingCall,
|
||||||
hangupActiveCall,
|
hangupActiveCall,
|
||||||
|
setCameraEnabled,
|
||||||
setMicMuted,
|
setMicMuted,
|
||||||
subscribeCallState,
|
subscribeCallState,
|
||||||
toggleSpeakerMode,
|
toggleSpeakerMode,
|
||||||
@@ -15,10 +16,16 @@ let panelEl = null;
|
|||||||
let headerEl = null;
|
let headerEl = null;
|
||||||
let titleEl = null;
|
let titleEl = null;
|
||||||
let statusEl = null;
|
let statusEl = null;
|
||||||
|
let mediaStageEl = null;
|
||||||
|
let remoteVideoEl = null;
|
||||||
|
let remoteVideoPlaceholderEl = null;
|
||||||
|
let localPreviewWrapEl = null;
|
||||||
|
let localVideoEl = null;
|
||||||
let incomingActionsEl = null;
|
let incomingActionsEl = null;
|
||||||
let activeActionsEl = null;
|
let activeActionsEl = null;
|
||||||
let speakerBtn = null;
|
let speakerBtn = null;
|
||||||
let muteBtn = null;
|
let muteBtn = null;
|
||||||
|
let cameraBtn = null;
|
||||||
let acceptBtn = null;
|
let acceptBtn = null;
|
||||||
let declineBtn = null;
|
let declineBtn = null;
|
||||||
let hangupBtn = null;
|
let hangupBtn = null;
|
||||||
@@ -28,6 +35,7 @@ let barTextEl = null;
|
|||||||
let barActionsEl = null;
|
let barActionsEl = null;
|
||||||
let barSpeakerBtn = null;
|
let barSpeakerBtn = null;
|
||||||
let barMuteBtn = null;
|
let barMuteBtn = null;
|
||||||
|
let barCameraBtn = null;
|
||||||
let barHangupBtn = null;
|
let barHangupBtn = null;
|
||||||
let unbind = null;
|
let unbind = null;
|
||||||
let tickerId = 0;
|
let tickerId = 0;
|
||||||
@@ -87,6 +95,12 @@ function getIconSvg(name) {
|
|||||||
if (name === 'speaker-off') {
|
if (name === 'speaker-off') {
|
||||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||||
}
|
}
|
||||||
|
if (name === 'camera-on') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
|
||||||
|
}
|
||||||
|
if (name === 'camera-off') {
|
||||||
|
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||||||
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +127,15 @@ function updateSpeakerButtons(enabled) {
|
|||||||
setButtonIcon(barSpeakerBtn, iconName, label);
|
setButtonIcon(barSpeakerBtn, iconName, label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateCameraButtons(enabled) {
|
||||||
|
const iconName = enabled ? 'camera-on' : 'camera-off';
|
||||||
|
const label = enabled ? 'Выключить камеру' : 'Включить камеру';
|
||||||
|
setButtonIcon(cameraBtn, iconName, label);
|
||||||
|
setButtonIcon(barCameraBtn, iconName, label);
|
||||||
|
if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0';
|
||||||
|
if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMuteClick(event) {
|
async function handleMuteClick(event) {
|
||||||
stopEvent(event);
|
stopEvent(event);
|
||||||
const nowMuted = event.currentTarget?.dataset?.muted === '1';
|
const nowMuted = event.currentTarget?.dataset?.muted === '1';
|
||||||
@@ -134,6 +157,14 @@ async function handleSpeakerClick(event) {
|
|||||||
await toggleSpeakerMode();
|
await toggleSpeakerMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCameraClick(event) {
|
||||||
|
stopEvent(event);
|
||||||
|
const enabled = event.currentTarget?.dataset?.enabled === '1';
|
||||||
|
try {
|
||||||
|
await setCameraEnabled(!enabled);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function setMinimized(nextValue) {
|
function setMinimized(nextValue) {
|
||||||
minimized = Boolean(nextValue);
|
minimized = Boolean(nextValue);
|
||||||
render();
|
render();
|
||||||
@@ -185,12 +216,17 @@ function ensureUi() {
|
|||||||
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||||
barMuteBtn.addEventListener('click', handleMuteClick);
|
barMuteBtn.addEventListener('click', handleMuteClick);
|
||||||
|
|
||||||
|
barCameraBtn = document.createElement('button');
|
||||||
|
barCameraBtn.type = 'button';
|
||||||
|
barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
|
||||||
|
barCameraBtn.addEventListener('click', handleCameraClick);
|
||||||
|
|
||||||
barHangupBtn = document.createElement('button');
|
barHangupBtn = document.createElement('button');
|
||||||
barHangupBtn.type = 'button';
|
barHangupBtn.type = 'button';
|
||||||
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
|
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
|
||||||
barHangupBtn.addEventListener('click', handleEndCallClick);
|
barHangupBtn.addEventListener('click', handleEndCallClick);
|
||||||
|
|
||||||
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
|
barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn);
|
||||||
barEl.append(barTextEl, barActionsEl);
|
barEl.append(barTextEl, barActionsEl);
|
||||||
|
|
||||||
overlayEl = document.createElement('div');
|
overlayEl = document.createElement('div');
|
||||||
@@ -219,6 +255,31 @@ function ensureUi() {
|
|||||||
statusEl = document.createElement('div');
|
statusEl = document.createElement('div');
|
||||||
statusEl.className = 'call-overlay-status';
|
statusEl.className = 'call-overlay-status';
|
||||||
|
|
||||||
|
mediaStageEl = document.createElement('div');
|
||||||
|
mediaStageEl.className = 'call-video-stage';
|
||||||
|
|
||||||
|
remoteVideoEl = document.createElement('video');
|
||||||
|
remoteVideoEl.className = 'call-remote-video';
|
||||||
|
remoteVideoEl.autoplay = true;
|
||||||
|
remoteVideoEl.playsInline = true;
|
||||||
|
remoteVideoEl.muted = true;
|
||||||
|
|
||||||
|
remoteVideoPlaceholderEl = document.createElement('div');
|
||||||
|
remoteVideoPlaceholderEl.className = 'call-video-placeholder';
|
||||||
|
remoteVideoPlaceholderEl.textContent = 'Собеседник пока без видео';
|
||||||
|
|
||||||
|
localPreviewWrapEl = document.createElement('div');
|
||||||
|
localPreviewWrapEl.className = 'call-local-preview';
|
||||||
|
|
||||||
|
localVideoEl = document.createElement('video');
|
||||||
|
localVideoEl.className = 'call-local-video';
|
||||||
|
localVideoEl.autoplay = true;
|
||||||
|
localVideoEl.playsInline = true;
|
||||||
|
localVideoEl.muted = true;
|
||||||
|
|
||||||
|
localPreviewWrapEl.append(localVideoEl);
|
||||||
|
mediaStageEl.append(remoteVideoEl, remoteVideoPlaceholderEl, localPreviewWrapEl);
|
||||||
|
|
||||||
incomingActionsEl = document.createElement('div');
|
incomingActionsEl = document.createElement('div');
|
||||||
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
|
incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming';
|
||||||
|
|
||||||
@@ -235,6 +296,11 @@ function ensureUi() {
|
|||||||
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||||
muteBtn.addEventListener('click', handleMuteClick);
|
muteBtn.addEventListener('click', handleMuteClick);
|
||||||
|
|
||||||
|
cameraBtn = document.createElement('button');
|
||||||
|
cameraBtn.type = 'button';
|
||||||
|
cameraBtn.className = 'call-icon-btn call-icon-btn--secondary';
|
||||||
|
cameraBtn.addEventListener('click', handleCameraClick);
|
||||||
|
|
||||||
acceptBtn = document.createElement('button');
|
acceptBtn = document.createElement('button');
|
||||||
acceptBtn.type = 'button';
|
acceptBtn.type = 'button';
|
||||||
acceptBtn.className = 'call-accept-btn';
|
acceptBtn.className = 'call-accept-btn';
|
||||||
@@ -258,9 +324,9 @@ function ensureUi() {
|
|||||||
hangupBtn.addEventListener('click', handleEndCallClick);
|
hangupBtn.addEventListener('click', handleEndCallClick);
|
||||||
|
|
||||||
incomingActionsEl.append(acceptBtn, declineBtn);
|
incomingActionsEl.append(acceptBtn, declineBtn);
|
||||||
activeActionsEl.append(speakerBtn, muteBtn, hangupBtn);
|
activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn);
|
||||||
headerEl.append(titleEl, minimizeBtn);
|
headerEl.append(titleEl, minimizeBtn);
|
||||||
panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl);
|
panelEl.append(headerEl, statusEl, mediaStageEl, incomingActionsEl, activeActionsEl);
|
||||||
overlayEl.append(panelEl);
|
overlayEl.append(panelEl);
|
||||||
rootEl.append(barEl, overlayEl);
|
rootEl.append(barEl, overlayEl);
|
||||||
resolveAppShell().append(rootEl);
|
resolveAppShell().append(rootEl);
|
||||||
@@ -277,6 +343,7 @@ function applyExpandedState(snapshot) {
|
|||||||
const muted = Boolean(snapshot.muted);
|
const muted = Boolean(snapshot.muted);
|
||||||
updateMuteButtons(muted);
|
updateMuteButtons(muted);
|
||||||
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||||
|
updateCameraButtons(Boolean(snapshot.cameraEnabled));
|
||||||
|
|
||||||
minimizeBtn.hidden = incomingStage;
|
minimizeBtn.hidden = incomingStage;
|
||||||
minimizeBtn.disabled = incomingStage;
|
minimizeBtn.disabled = incomingStage;
|
||||||
@@ -290,9 +357,21 @@ function applyExpandedState(snapshot) {
|
|||||||
muteBtn.hidden = !snapshot.canMute;
|
muteBtn.hidden = !snapshot.canMute;
|
||||||
muteBtn.disabled = !snapshot.canMute;
|
muteBtn.disabled = !snapshot.canMute;
|
||||||
|
|
||||||
|
cameraBtn.hidden = !snapshot.canToggleCamera;
|
||||||
|
cameraBtn.disabled = !snapshot.canToggleCamera;
|
||||||
|
|
||||||
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
|
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
|
||||||
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
|
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
|
||||||
hangupBtn.hidden = !snapshot.canHangup;
|
hangupBtn.hidden = !snapshot.canHangup;
|
||||||
|
|
||||||
|
mediaStageEl.hidden = incomingStage;
|
||||||
|
const remoteStream = snapshot.remoteMediaStream || null;
|
||||||
|
const localStream = snapshot.localPreviewStream || null;
|
||||||
|
if (remoteVideoEl.srcObject !== remoteStream) remoteVideoEl.srcObject = remoteStream;
|
||||||
|
if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream;
|
||||||
|
remoteVideoEl.hidden = !snapshot.hasRemoteVideo;
|
||||||
|
remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo;
|
||||||
|
localPreviewWrapEl.hidden = !snapshot.hasLocalVideo;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyMinimizedState(snapshot) {
|
function applyMinimizedState(snapshot) {
|
||||||
@@ -303,15 +382,18 @@ function applyMinimizedState(snapshot) {
|
|||||||
const muted = Boolean(snapshot.muted);
|
const muted = Boolean(snapshot.muted);
|
||||||
updateMuteButtons(muted);
|
updateMuteButtons(muted);
|
||||||
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
|
||||||
|
updateCameraButtons(Boolean(snapshot.cameraEnabled));
|
||||||
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
|
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
|
||||||
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
|
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
|
||||||
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
|
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
|
||||||
barMuteBtn.disabled = !snapshot.canMute;
|
barMuteBtn.disabled = !snapshot.canMute;
|
||||||
|
barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera;
|
||||||
|
barCameraBtn.disabled = !snapshot.canToggleCamera;
|
||||||
const canEnd = !incomingStage && snapshot.canHangup;
|
const canEnd = !incomingStage && snapshot.canHangup;
|
||||||
barHangupBtn.hidden = !canEnd;
|
barHangupBtn.hidden = !canEnd;
|
||||||
barHangupBtn.disabled = !canEnd;
|
barHangupBtn.disabled = !canEnd;
|
||||||
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
|
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
|
||||||
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd);
|
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncShellState() {
|
function syncShellState() {
|
||||||
|
|||||||
@@ -2001,6 +2001,56 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-video-stage {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 220px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(41, 72, 118, 0.48), transparent 58%),
|
||||||
|
linear-gradient(180deg, rgba(7, 14, 27, 0.98), rgba(3, 7, 14, 1));
|
||||||
|
border: 1px solid rgba(118, 154, 228, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-remote-video,
|
||||||
|
.call-local-video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #02050a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-remote-video[hidden],
|
||||||
|
.call-local-preview[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-video-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
color: rgba(223, 234, 255, 0.72);
|
||||||
|
font-size: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-local-preview {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: min(32vw, 128px);
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(176, 205, 255, 0.38);
|
||||||
|
background: rgba(7, 12, 22, 0.94);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.call-overlay-controls {
|
.call-overlay-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user