|
|
|
@@ -53,6 +53,11 @@ const CAMERA_CONSTRAINTS = Object.freeze({
|
|
|
|
|
frameRate: { ideal: 24, max: 30 },
|
|
|
|
|
facingMode: 'user',
|
|
|
|
|
});
|
|
|
|
|
const CALL_MODE_AUDIO = 'audio';
|
|
|
|
|
const CALL_MODE_VIDEO = 'video';
|
|
|
|
|
const CALL_MODE_INSTANT_VIDEO = 'instant_video';
|
|
|
|
|
const VIDEO_TRANSPORT_NONE = 'none';
|
|
|
|
|
const VIDEO_TRANSPORT_SLOT = 'slot';
|
|
|
|
|
|
|
|
|
|
function nowMs() {
|
|
|
|
|
return Date.now();
|
|
|
|
@@ -103,6 +108,66 @@ function buildConnectStartData(winnerSessionId) {
|
|
|
|
|
return `session=${String(winnerSessionId || '').trim()}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeCallMode(mode) {
|
|
|
|
|
const normalized = String(mode || '').trim().toLowerCase();
|
|
|
|
|
if (normalized === CALL_MODE_AUDIO) return CALL_MODE_AUDIO;
|
|
|
|
|
if (normalized === CALL_MODE_INSTANT_VIDEO) return CALL_MODE_INSTANT_VIDEO;
|
|
|
|
|
return CALL_MODE_VIDEO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getVideoTransportModeForCallMode(mode) {
|
|
|
|
|
return normalizeCallMode(mode) === CALL_MODE_AUDIO ? VIDEO_TRANSPORT_NONE : VIDEO_TRANSPORT_SLOT;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInstantVideoMode(mode) {
|
|
|
|
|
return normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isVideoCallMode(mode) {
|
|
|
|
|
const normalized = normalizeCallMode(mode);
|
|
|
|
|
return normalized === CALL_MODE_VIDEO || normalized === CALL_MODE_INSTANT_VIDEO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildInviteData({ mode } = {}) {
|
|
|
|
|
return JSON.stringify({
|
|
|
|
|
mode: normalizeCallMode(mode),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseInviteData(data = '') {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(String(data || '{}'));
|
|
|
|
|
return {
|
|
|
|
|
mode: normalizeCallMode(parsed?.mode || CALL_MODE_VIDEO),
|
|
|
|
|
};
|
|
|
|
|
} catch {
|
|
|
|
|
return {
|
|
|
|
|
mode: CALL_MODE_VIDEO,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCallTitleText(mode) {
|
|
|
|
|
if (normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO) {
|
|
|
|
|
return 'Видеозвонок';
|
|
|
|
|
}
|
|
|
|
|
if (normalizeCallMode(mode) === CALL_MODE_VIDEO) {
|
|
|
|
|
return 'Звонок с поддержкой видео';
|
|
|
|
|
}
|
|
|
|
|
return 'Звонок';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getIncomingCallStatusText(peerLogin, mode) {
|
|
|
|
|
const name = String(peerLogin || '').trim();
|
|
|
|
|
if (normalizeCallMode(mode) === CALL_MODE_INSTANT_VIDEO) {
|
|
|
|
|
return `Входящий видеозвонок от ${name}`;
|
|
|
|
|
}
|
|
|
|
|
if (isVideoCallMode(mode)) {
|
|
|
|
|
return `Вам звонит ${name} (звонок с поддержкой видео)`;
|
|
|
|
|
}
|
|
|
|
|
return `Вам звонит ${name}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseConnectStartWinnerSessionId(data = '') {
|
|
|
|
|
const raw = String(data || '').trim();
|
|
|
|
|
if (!raw) return '';
|
|
|
|
@@ -647,6 +712,8 @@ function getCallStateSnapshot() {
|
|
|
|
|
return {
|
|
|
|
|
callId: call.callId,
|
|
|
|
|
peerLogin: call.peerLogin || '',
|
|
|
|
|
titleText: getCallTitleText(call.callMode),
|
|
|
|
|
callMode: normalizeCallMode(call.callMode),
|
|
|
|
|
direction: call.direction || 'out',
|
|
|
|
|
phase: callPhase,
|
|
|
|
|
statusText: call.statusText || '',
|
|
|
|
@@ -661,9 +728,14 @@ function getCallStateSnapshot() {
|
|
|
|
|
canDecline: callPhase === 'incoming',
|
|
|
|
|
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
|
|
|
|
|
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,
|
|
|
|
|
hasLocalVideo: Boolean(
|
|
|
|
|
call.cameraEnabled
|
|
|
|
|
&& call.localVideoTrack
|
|
|
|
|
&& call.localVideoTrack.readyState === 'live'
|
|
|
|
|
&& call.localVideoTrack.enabled !== false
|
|
|
|
|
),
|
|
|
|
|
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live' && !track?.muted),
|
|
|
|
|
localPreviewStream: call.cameraEnabled ? (call.localStream || null) : null,
|
|
|
|
|
remoteMediaStream: call.remoteMediaStream || null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -701,7 +773,23 @@ function setStatus(call, statusText, phase = '') {
|
|
|
|
|
|
|
|
|
|
function buildActiveStatusText(call) {
|
|
|
|
|
const route = String(call?.connectionRouteLabel || '').trim();
|
|
|
|
|
return route ? `Разговор идёт (${route})` : 'Разговор идёт';
|
|
|
|
|
const videoSlotMarker = call?.videoSlotReady ? ' +' : '';
|
|
|
|
|
return route ? `Разговор идёт (${route}${videoSlotMarker})` : 'Разговор идёт';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function refreshVideoSlotReady(call) {
|
|
|
|
|
if (!call?.pc) return false;
|
|
|
|
|
const localSdp = String(call.pc.localDescription?.sdp || '');
|
|
|
|
|
const remoteSdp = String(call.pc.remoteDescription?.sdp || '');
|
|
|
|
|
const hasVideoInLocal = localSdp.includes('\nm=video') || localSdp.startsWith('m=video');
|
|
|
|
|
const hasVideoInRemote = remoteSdp.includes('\nm=video') || remoteSdp.startsWith('m=video');
|
|
|
|
|
call.videoSlotReady = Boolean(hasVideoInLocal && hasVideoInRemote && call.videoTransceiver);
|
|
|
|
|
return call.videoSlotReady;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasVideoSectionInSdp(sdp = '') {
|
|
|
|
|
const text = String(sdp || '');
|
|
|
|
|
return text.includes('\nm=video') || text.startsWith('m=video');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setActiveStatus(call) {
|
|
|
|
@@ -1251,6 +1339,12 @@ async function finalizeCall(call, {
|
|
|
|
|
&& Boolean(call.remoteSessionId)
|
|
|
|
|
&& String(localReasonCode || '') !== 'completed'
|
|
|
|
|
&& String(debugReason || '') !== 'remote_hangup';
|
|
|
|
|
const shouldBroadcastPreAcceptHangup =
|
|
|
|
|
!suppressRemoteSignal
|
|
|
|
|
&& notifyRemoteHangup
|
|
|
|
|
&& !call.remoteSessionId
|
|
|
|
|
&& call.direction === 'out'
|
|
|
|
|
&& !call.connectedAtMs;
|
|
|
|
|
|
|
|
|
|
if ((notifyRemoteHangup || shouldNotifyRemoteFailure) && call.remoteSessionId) {
|
|
|
|
|
try {
|
|
|
|
@@ -1260,6 +1354,11 @@ async function finalizeCall(call, {
|
|
|
|
|
await sendSignal(call, TYPES.HANGUP, dataValue);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
if (shouldBroadcastPreAcceptHangup) {
|
|
|
|
|
try {
|
|
|
|
|
await broadcastSignal(call, TYPES.HANGUP, 'caller_cancelled_before_accept');
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await closeMedia(call);
|
|
|
|
|
if (String(localReasonCode || '') === 'completed') {
|
|
|
|
@@ -1416,6 +1515,75 @@ async function notifyPeerSessionsAboutConnectStart(call) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureReservedVideoTransceiver(call) {
|
|
|
|
|
const pc = call?.pc || null;
|
|
|
|
|
if (!pc) return null;
|
|
|
|
|
if (call.videoTransceiver) return call.videoTransceiver;
|
|
|
|
|
const transceivers = pc.getTransceivers?.() || [];
|
|
|
|
|
const existing = transceivers.find((tr) => tr?.mid !== null && tr?.mid !== undefined && (tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video'))
|
|
|
|
|
|| transceivers.find((tr) => tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video')
|
|
|
|
|
|| null;
|
|
|
|
|
if (existing) {
|
|
|
|
|
call.videoTransceiver = existing;
|
|
|
|
|
call.videoSender = existing?.sender || call.videoSender || null;
|
|
|
|
|
return existing;
|
|
|
|
|
}
|
|
|
|
|
const created = pc.addTransceiver('video', { direction: 'sendrecv' });
|
|
|
|
|
call.videoTransceiver = created;
|
|
|
|
|
call.videoSender = created?.sender || null;
|
|
|
|
|
return created;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureRemoteTrackObservers(call, track) {
|
|
|
|
|
if (!call || !track || typeof track !== 'object') return;
|
|
|
|
|
if (!call.remoteTrackObserverIds) {
|
|
|
|
|
call.remoteTrackObserverIds = new Set();
|
|
|
|
|
}
|
|
|
|
|
if (call.remoteTrackObserverIds.has(track.id)) return;
|
|
|
|
|
call.remoteTrackObserverIds.add(track.id);
|
|
|
|
|
track.onended = () => {
|
|
|
|
|
if (track.kind === 'video' && call.remoteMediaStream) {
|
|
|
|
|
try { call.remoteMediaStream.removeTrack(track); } catch {}
|
|
|
|
|
}
|
|
|
|
|
notifyCallState();
|
|
|
|
|
};
|
|
|
|
|
track.onmute = () => notifyCallState();
|
|
|
|
|
track.onunmute = () => notifyCallState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureRemoteMediaStream(call) {
|
|
|
|
|
if (call?.remoteMediaStream) return call.remoteMediaStream;
|
|
|
|
|
const stream = new MediaStream();
|
|
|
|
|
if (call) {
|
|
|
|
|
call.remoteMediaStream = stream;
|
|
|
|
|
}
|
|
|
|
|
return stream;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncRemoteReceiverVideoTracks(call) {
|
|
|
|
|
const pc = call?.pc || null;
|
|
|
|
|
if (!pc) return false;
|
|
|
|
|
const remoteStream = ensureRemoteMediaStream(call);
|
|
|
|
|
let changed = false;
|
|
|
|
|
const receivers = pc.getReceivers?.() || [];
|
|
|
|
|
receivers.forEach((receiver) => {
|
|
|
|
|
const track = receiver?.track || null;
|
|
|
|
|
if (!track || track.kind !== 'video' || track.readyState === 'ended') return;
|
|
|
|
|
const hasTrack = remoteStream.getVideoTracks().some((existing) => existing.id === track.id);
|
|
|
|
|
if (!hasTrack) {
|
|
|
|
|
try {
|
|
|
|
|
remoteStream.addTrack(track);
|
|
|
|
|
changed = true;
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
ensureRemoteTrackObservers(call, track);
|
|
|
|
|
});
|
|
|
|
|
if (call.remoteAudio) {
|
|
|
|
|
call.remoteAudio.srcObject = remoteStream;
|
|
|
|
|
}
|
|
|
|
|
return changed;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensurePeerConnection(call) {
|
|
|
|
|
if (call.pc) return call.pc;
|
|
|
|
|
|
|
|
|
@@ -1479,6 +1647,7 @@ async function ensurePeerConnection(call) {
|
|
|
|
|
if (!call.connectedAtMs) {
|
|
|
|
|
call.connectedAtMs = nowMs();
|
|
|
|
|
}
|
|
|
|
|
refreshVideoSlotReady(call);
|
|
|
|
|
setActiveStatus(call);
|
|
|
|
|
startTransportProbe(call);
|
|
|
|
|
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
|
|
|
|
@@ -1538,11 +1707,25 @@ async function ensurePeerConnection(call) {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
pc.onsignalingstatechange = () => {
|
|
|
|
|
const state = String(pc.signalingState || '');
|
|
|
|
|
recordCallTimeline(call, 'pc_signaling_state', `state=${state || ''}`);
|
|
|
|
|
if (state === 'stable') {
|
|
|
|
|
if (syncRemoteReceiverVideoTracks(call)) {
|
|
|
|
|
notifyCallState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (state === 'stable' && call.pendingNegotiationReason) {
|
|
|
|
|
void renegotiateCall(call, `signaling_stable:${call.pendingNegotiationReason}`);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
|
|
|
call.localStream = stream;
|
|
|
|
|
call.audioSenders = [];
|
|
|
|
|
call.videoSender = null;
|
|
|
|
|
call.videoTransceiver = null;
|
|
|
|
|
call.connectionRouteLabel = '';
|
|
|
|
|
call.connectionRouteDetails = '';
|
|
|
|
|
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
|
|
|
|
@@ -1553,6 +1736,27 @@ async function ensurePeerConnection(call) {
|
|
|
|
|
call.audioSenders.push(sender);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT) {
|
|
|
|
|
const blackTrack = ensureBlackVideoTrack(call);
|
|
|
|
|
if (blackTrack) {
|
|
|
|
|
const existingVideoTracks = stream.getVideoTracks?.() || [];
|
|
|
|
|
existingVideoTracks.forEach((track) => {
|
|
|
|
|
if (track.id !== blackTrack.id) {
|
|
|
|
|
try { stream.removeTrack(track); } catch {}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
|
|
|
|
stream.addTrack(blackTrack);
|
|
|
|
|
}
|
|
|
|
|
const sender = pc.addTrack(blackTrack, stream);
|
|
|
|
|
call.videoSender = sender;
|
|
|
|
|
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
|
|
|
|
|
bindVideoSenderStream(call, sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(sender, 'placeholder');
|
|
|
|
|
} else {
|
|
|
|
|
ensureReservedVideoTransceiver(call);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
|
|
|
|
|
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
|
|
|
|
@@ -1573,18 +1777,18 @@ async function ensurePeerConnection(call) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
call.remoteMediaStream = remoteStream;
|
|
|
|
|
if (trackKind === 'video') {
|
|
|
|
|
call.videoEverActivated = true;
|
|
|
|
|
}
|
|
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
if (evt?.track) ensureRemoteTrackObservers(call, evt.track);
|
|
|
|
|
call.remoteAudio.srcObject = remoteStream;
|
|
|
|
|
syncRemoteReceiverVideoTracks(call);
|
|
|
|
|
void call.remoteAudio.play?.().catch?.(() => {});
|
|
|
|
|
notifyCallState();
|
|
|
|
|
};
|
|
|
|
@@ -1596,27 +1800,47 @@ async function ensurePeerConnection(call) {
|
|
|
|
|
return pc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function queueNegotiationReason(call, reason = '') {
|
|
|
|
|
if (!call) return '';
|
|
|
|
|
const normalized = String(reason || '').trim() || 'pending';
|
|
|
|
|
call.pendingNegotiationReason = normalized;
|
|
|
|
|
return normalized;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function renegotiateCall(call, reason = '') {
|
|
|
|
|
if (!call?.pc || !call.remoteSessionId) return false;
|
|
|
|
|
const queuedReason = queueNegotiationReason(call, reason);
|
|
|
|
|
if (call.negotiationInProgress) {
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_skipped_busy', `reason=${reason || '-'}`);
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_queued_busy', `reason=${queuedReason || '-'}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const pc = call.pc;
|
|
|
|
|
if (pc.signalingState !== 'stable') {
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_queued_unstable', `state=${pc.signalingState}; reason=${queuedReason || '-'}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
const activeReason = String(call.pendingNegotiationReason || queuedReason || 'pending').trim() || 'pending';
|
|
|
|
|
call.pendingNegotiationReason = '';
|
|
|
|
|
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 || '-'}`);
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_start', `reason=${activeReason || '-'}`);
|
|
|
|
|
const offer = await pc.createOffer();
|
|
|
|
|
await pc.setLocalDescription(offer);
|
|
|
|
|
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${reason || '-'}`);
|
|
|
|
|
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${activeReason || '-'}`);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (!call.pendingNegotiationReason) {
|
|
|
|
|
call.pendingNegotiationReason = activeReason;
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
call.negotiationInProgress = false;
|
|
|
|
|
if (call.pendingNegotiationReason && call.pc?.signalingState === 'stable') {
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
void renegotiateCall(call, `queued:${call.pendingNegotiationReason}`);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1633,6 +1857,14 @@ async function onAccept(call) {
|
|
|
|
|
try {
|
|
|
|
|
await notifyPeerSessionsAboutConnectStart(call);
|
|
|
|
|
const pc = await ensurePeerConnection(call);
|
|
|
|
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT) {
|
|
|
|
|
ensureReservedVideoTransceiver(call);
|
|
|
|
|
}
|
|
|
|
|
if (call.autoEnableCameraOnConnect && call.cameraEnabled) {
|
|
|
|
|
try {
|
|
|
|
|
await applyCameraState(call);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
const offer = await pc.createOffer();
|
|
|
|
|
await pc.setLocalDescription(offer);
|
|
|
|
|
call.initialOfferSent = true;
|
|
|
|
@@ -1643,9 +1875,9 @@ async function onAccept(call) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureIncomingNotification(peerLogin) {
|
|
|
|
|
function ensureIncomingNotification(peerLogin, mode = CALL_MODE_AUDIO) {
|
|
|
|
|
if (typeof window === 'undefined') return;
|
|
|
|
|
const text = `Вам звонит ${peerLogin}`;
|
|
|
|
|
const text = getIncomingCallStatusText(peerLogin, mode);
|
|
|
|
|
try {
|
|
|
|
|
if ('Notification' in window && Notification.permission === 'granted') {
|
|
|
|
|
new Notification('SHiNE: входящий звонок', { body: text });
|
|
|
|
@@ -1678,6 +1910,8 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|
|
|
|
const fromLogin = String(payload?.fromLogin || '').trim();
|
|
|
|
|
const fromSessionId = String(payload?.fromSessionId || '').trim();
|
|
|
|
|
const type = Number(payload?.type || TYPES.INVITE);
|
|
|
|
|
const inviteMeta = parseInviteData(payload?.data || '');
|
|
|
|
|
const callMode = inviteMeta.mode;
|
|
|
|
|
if (type === TYPES.CONNECT_START) {
|
|
|
|
|
const call = getCall(callId);
|
|
|
|
|
if (!call || call.direction !== 'in' || call.phase === 'ended') return call;
|
|
|
|
@@ -1721,9 +1955,12 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|
|
|
|
call = {
|
|
|
|
|
callId,
|
|
|
|
|
peerLogin: fromLogin,
|
|
|
|
|
callMode,
|
|
|
|
|
videoTransportMode: getVideoTransportModeForCallMode(callMode),
|
|
|
|
|
autoEnableCameraOnConnect: isInstantVideoMode(callMode),
|
|
|
|
|
direction: 'in',
|
|
|
|
|
phase: 'incoming',
|
|
|
|
|
statusText: `Вам звонит ${fromLogin}`,
|
|
|
|
|
statusText: getIncomingCallStatusText(fromLogin, callMode),
|
|
|
|
|
remoteSessionId: fromSessionId,
|
|
|
|
|
timers: {},
|
|
|
|
|
startedAtMs: nowMs(),
|
|
|
|
@@ -1734,8 +1971,10 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|
|
|
|
remoteMediaStream: null,
|
|
|
|
|
audioSenders: [],
|
|
|
|
|
videoSender: null,
|
|
|
|
|
videoTransceiver: null,
|
|
|
|
|
muted: false,
|
|
|
|
|
cameraEnabled: false,
|
|
|
|
|
videoEverActivated: false,
|
|
|
|
|
connectionRouteLabel: '',
|
|
|
|
|
reconnectInProgress: false,
|
|
|
|
|
reconnectAttempts: 0,
|
|
|
|
@@ -1743,6 +1982,7 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|
|
|
|
debugRunId: '',
|
|
|
|
|
debugRole: '',
|
|
|
|
|
pendingRemoteIceCandidates: [],
|
|
|
|
|
pendingNegotiationReason: '',
|
|
|
|
|
initialOfferInProgress: false,
|
|
|
|
|
initialOfferSent: false,
|
|
|
|
|
timelineEvents: [],
|
|
|
|
@@ -1755,9 +1995,15 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
|
|
|
|
|
setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!call.callMode) call.callMode = callMode;
|
|
|
|
|
if (!call.videoTransportMode) call.videoTransportMode = getVideoTransportModeForCallMode(call.callMode);
|
|
|
|
|
if (typeof call.autoEnableCameraOnConnect !== 'boolean') {
|
|
|
|
|
call.autoEnableCameraOnConnect = isInstantVideoMode(call.callMode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
activeCallId = callId;
|
|
|
|
|
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
|
|
|
|
ensureIncomingNotification(fromLogin);
|
|
|
|
|
setStatus(call, getIncomingCallStatusText(fromLogin, call.callMode), 'incoming');
|
|
|
|
|
ensureIncomingNotification(fromLogin, call.callMode);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await sendSignal(call, TYPES.RINGING, `ringing:${source}`);
|
|
|
|
@@ -1855,9 +2101,60 @@ async function ensureCameraTrack(call) {
|
|
|
|
|
});
|
|
|
|
|
call.localStream.addTrack(videoTrack);
|
|
|
|
|
call.localVideoTrack = videoTrack;
|
|
|
|
|
call.videoEverActivated = true;
|
|
|
|
|
return videoTrack;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ensureBlackVideoTrack(call) {
|
|
|
|
|
const existingTrack = call?.placeholderBlackVideoTrack || null;
|
|
|
|
|
if (existingTrack && existingTrack.readyState === 'live') {
|
|
|
|
|
existingTrack.enabled = true;
|
|
|
|
|
return existingTrack;
|
|
|
|
|
}
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = 160;
|
|
|
|
|
canvas.height = 90;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx || typeof canvas.captureStream !== 'function') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
ctx.fillStyle = '#000000';
|
|
|
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
|
const stream = canvas.captureStream(1);
|
|
|
|
|
const track = stream.getVideoTracks?.()[0] || null;
|
|
|
|
|
if (!track) return null;
|
|
|
|
|
call.placeholderBlackCanvas = canvas;
|
|
|
|
|
call.placeholderBlackStream = stream;
|
|
|
|
|
call.placeholderBlackVideoTrack = track;
|
|
|
|
|
return track;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyVideoSenderEncodingProfile(sender, mode = 'camera') {
|
|
|
|
|
if (!sender || typeof sender.getParameters !== 'function' || typeof sender.setParameters !== 'function') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const params = sender.getParameters() || {};
|
|
|
|
|
const encodings = Array.isArray(params.encodings) && params.encodings.length > 0
|
|
|
|
|
? [...params.encodings]
|
|
|
|
|
: [{}];
|
|
|
|
|
const nextEncoding = { ...encodings[0] };
|
|
|
|
|
if (mode === 'placeholder') {
|
|
|
|
|
nextEncoding.maxBitrate = 12000;
|
|
|
|
|
nextEncoding.maxFramerate = 1;
|
|
|
|
|
nextEncoding.scaleResolutionDownBy = 1;
|
|
|
|
|
nextEncoding.active = true;
|
|
|
|
|
} else {
|
|
|
|
|
delete nextEncoding.maxBitrate;
|
|
|
|
|
delete nextEncoding.maxFramerate;
|
|
|
|
|
delete nextEncoding.scaleResolutionDownBy;
|
|
|
|
|
nextEncoding.active = true;
|
|
|
|
|
}
|
|
|
|
|
params.encodings = [nextEncoding];
|
|
|
|
|
await sender.setParameters(params);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function disableCameraTrack(call) {
|
|
|
|
|
const videoTrack = call?.localVideoTrack || null;
|
|
|
|
|
if (videoTrack) {
|
|
|
|
@@ -1868,36 +2165,104 @@ async function disableCameraTrack(call) {
|
|
|
|
|
call.localVideoTrack = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyCameraState(call, { renegotiate = true, reason = '' } = {}) {
|
|
|
|
|
function bindVideoSenderStream(call, sender) {
|
|
|
|
|
if (!call?.localStream || !sender || typeof sender.setStreams !== 'function') return;
|
|
|
|
|
try {
|
|
|
|
|
sender.setStreams(call.localStream);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function applyCameraState(call) {
|
|
|
|
|
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;
|
|
|
|
|
const hadSlotBefore = call.videoTransportMode === VIDEO_TRANSPORT_SLOT;
|
|
|
|
|
let videoTransceiver = call.videoTransceiver || null;
|
|
|
|
|
if (!videoTransceiver) {
|
|
|
|
|
const transceivers = pc.getTransceivers?.() || [];
|
|
|
|
|
videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
|
|
|
|
|
call.videoTransceiver = videoTransceiver;
|
|
|
|
|
call.videoSender = videoTransceiver?.sender || call.videoSender || 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;
|
|
|
|
|
if (hadSlotBefore) {
|
|
|
|
|
const videoTrack = await ensureCameraTrack(call);
|
|
|
|
|
if (!videoTransceiver) {
|
|
|
|
|
videoTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
|
|
|
|
|
call.videoTransceiver = videoTransceiver;
|
|
|
|
|
call.videoSender = videoTransceiver?.sender || null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
|
|
|
|
videoTransceiver.direction = 'sendrecv';
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
if (videoTransceiver?.sender) {
|
|
|
|
|
await videoTransceiver.sender.replaceTrack(videoTrack);
|
|
|
|
|
call.videoSender = videoTransceiver.sender;
|
|
|
|
|
bindVideoSenderStream(call, videoTransceiver.sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'camera');
|
|
|
|
|
} else {
|
|
|
|
|
const sender = pc.addTrack(videoTrack, call.localStream);
|
|
|
|
|
call.videoSender = sender;
|
|
|
|
|
bindVideoSenderStream(call, sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(sender, 'camera');
|
|
|
|
|
}
|
|
|
|
|
videoTrack.enabled = true;
|
|
|
|
|
} else {
|
|
|
|
|
await videoTransceiver.sender.replaceTrack(videoTrack);
|
|
|
|
|
call.videoSender = videoTransceiver.sender;
|
|
|
|
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
|
|
|
|
const videoTrack = await ensureCameraTrack(call);
|
|
|
|
|
await ensureVideoSenderSlot(call, { sourceTrack: videoTrack, profile: 'camera' });
|
|
|
|
|
videoTransceiver = call.videoTransceiver || videoTransceiver;
|
|
|
|
|
try {
|
|
|
|
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
|
|
|
|
videoTransceiver.direction = 'sendrecv';
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
videoTrack.enabled = true;
|
|
|
|
|
}
|
|
|
|
|
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 {}
|
|
|
|
|
const blackTrack = ensureBlackVideoTrack(call);
|
|
|
|
|
if (hadSlotBefore) {
|
|
|
|
|
if (call.localStream && blackTrack) {
|
|
|
|
|
const existingVideoTracks = call.localStream.getVideoTracks?.() || [];
|
|
|
|
|
existingVideoTracks.forEach((track) => {
|
|
|
|
|
if (track.id !== blackTrack.id) {
|
|
|
|
|
try { call.localStream.removeTrack(track); } catch {}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
|
|
|
|
|
call.localStream.addTrack(blackTrack);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
|
|
|
|
videoTransceiver.direction = 'sendrecv';
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
if (videoTransceiver?.sender) {
|
|
|
|
|
try { await videoTransceiver.sender.replaceTrack(blackTrack || null); } catch {}
|
|
|
|
|
call.videoSender = videoTransceiver.sender;
|
|
|
|
|
bindVideoSenderStream(call, videoTransceiver.sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(videoTransceiver.sender, 'placeholder');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (call.localStream && blackTrack) {
|
|
|
|
|
syncLocalStreamVideoTrack(call, blackTrack, { stopRemoved: false });
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
|
|
|
|
|
videoTransceiver.direction = 'sendrecv';
|
|
|
|
|
}
|
|
|
|
|
} catch {}
|
|
|
|
|
if (call.videoTransportMode === VIDEO_TRANSPORT_SLOT && blackTrack) {
|
|
|
|
|
await ensureVideoSenderSlot(call, { sourceTrack: blackTrack, profile: 'placeholder' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
await disableCameraTrack(call);
|
|
|
|
|
}
|
|
|
|
|
notifyCallState();
|
|
|
|
|
if (renegotiate) {
|
|
|
|
|
await renegotiateCall(call, reason || (call.cameraEnabled ? 'camera_on' : 'camera_off'));
|
|
|
|
|
if (!hadSlotBefore && call.videoTransportMode === VIDEO_TRANSPORT_SLOT && !refreshVideoSlotReady(call)) {
|
|
|
|
|
await renegotiateCall(call, 'upgrade_to_video_slot');
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
@@ -1923,7 +2288,7 @@ export async function setCameraEnabled(enabled) {
|
|
|
|
|
if (Boolean(call.cameraEnabled) === nextEnabled) return true;
|
|
|
|
|
call.cameraEnabled = nextEnabled;
|
|
|
|
|
try {
|
|
|
|
|
await applyCameraState(call, { renegotiate: true, reason: nextEnabled ? 'camera_on' : 'camera_off' });
|
|
|
|
|
await applyCameraState(call);
|
|
|
|
|
notifyCallState();
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
@@ -1933,6 +2298,55 @@ export async function setCameraEnabled(enabled) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncLocalStreamVideoTrack(call, preferredTrack, { stopRemoved = false } = {}) {
|
|
|
|
|
if (!call?.localStream || !preferredTrack) return;
|
|
|
|
|
const currentVideoTracks = call.localStream.getVideoTracks?.() || [];
|
|
|
|
|
currentVideoTracks.forEach((track) => {
|
|
|
|
|
if (track.id !== preferredTrack.id) {
|
|
|
|
|
try { call.localStream.removeTrack(track); } catch {}
|
|
|
|
|
if (stopRemoved) {
|
|
|
|
|
try { track.stop(); } catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
if (!currentVideoTracks.some((track) => track.id === preferredTrack.id)) {
|
|
|
|
|
call.localStream.addTrack(preferredTrack);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function ensureVideoSenderSlot(call, { sourceTrack = null, profile = 'placeholder' } = {}) {
|
|
|
|
|
const pc = call?.pc || null;
|
|
|
|
|
const stream = call?.localStream || null;
|
|
|
|
|
if (!pc || !stream) return null;
|
|
|
|
|
|
|
|
|
|
const track = sourceTrack || ensureBlackVideoTrack(call);
|
|
|
|
|
if (!track) return null;
|
|
|
|
|
syncLocalStreamVideoTrack(call, track, { stopRemoved: false });
|
|
|
|
|
|
|
|
|
|
let transceiver = call.videoTransceiver || null;
|
|
|
|
|
if (!transceiver) {
|
|
|
|
|
const transceivers = pc.getTransceivers?.() || [];
|
|
|
|
|
transceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
|
|
|
|
|
call.videoTransceiver = transceiver;
|
|
|
|
|
call.videoSender = transceiver?.sender || call.videoSender || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transceiver?.sender) {
|
|
|
|
|
try { await transceiver.sender.replaceTrack(track); } catch {}
|
|
|
|
|
call.videoSender = transceiver.sender;
|
|
|
|
|
bindVideoSenderStream(call, transceiver.sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(transceiver.sender, profile);
|
|
|
|
|
return transceiver.sender;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sender = pc.addTrack(track, stream);
|
|
|
|
|
call.videoSender = sender;
|
|
|
|
|
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
|
|
|
|
|
bindVideoSenderStream(call, sender);
|
|
|
|
|
await applyVideoSenderEncodingProfile(sender, profile);
|
|
|
|
|
return sender;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function toggleCameraEnabled() {
|
|
|
|
|
const call = getActiveCall();
|
|
|
|
|
if (!call) return false;
|
|
|
|
@@ -1980,6 +2394,9 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|
|
|
|
call = {
|
|
|
|
|
callId: cleanCallId,
|
|
|
|
|
peerLogin: cleanPeerLogin,
|
|
|
|
|
callMode: CALL_MODE_AUDIO,
|
|
|
|
|
videoTransportMode: VIDEO_TRANSPORT_NONE,
|
|
|
|
|
autoEnableCameraOnConnect: false,
|
|
|
|
|
direction: 'in',
|
|
|
|
|
phase: 'incoming',
|
|
|
|
|
statusText: 'Debug: responder ждёт offer',
|
|
|
|
@@ -1993,13 +2410,16 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|
|
|
|
remoteMediaStream: null,
|
|
|
|
|
audioSenders: [],
|
|
|
|
|
videoSender: null,
|
|
|
|
|
videoTransceiver: null,
|
|
|
|
|
muted: false,
|
|
|
|
|
cameraEnabled: false,
|
|
|
|
|
videoEverActivated: false,
|
|
|
|
|
connectionRouteLabel: '',
|
|
|
|
|
debugMode: true,
|
|
|
|
|
debugRunId: String(runId || '').trim(),
|
|
|
|
|
debugRole: 'responder',
|
|
|
|
|
pendingRemoteIceCandidates: [],
|
|
|
|
|
pendingNegotiationReason: '',
|
|
|
|
|
initialOfferInProgress: false,
|
|
|
|
|
initialOfferSent: false,
|
|
|
|
|
};
|
|
|
|
@@ -2023,6 +2443,9 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|
|
|
|
const call = {
|
|
|
|
|
callId: cleanCallId,
|
|
|
|
|
peerLogin: cleanPeerLogin,
|
|
|
|
|
callMode: CALL_MODE_AUDIO,
|
|
|
|
|
videoTransportMode: VIDEO_TRANSPORT_NONE,
|
|
|
|
|
autoEnableCameraOnConnect: false,
|
|
|
|
|
direction: 'out',
|
|
|
|
|
phase: 'connecting',
|
|
|
|
|
statusText: 'Debug: старт соединения',
|
|
|
|
@@ -2036,13 +2459,16 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|
|
|
|
remoteMediaStream: null,
|
|
|
|
|
audioSenders: [],
|
|
|
|
|
videoSender: null,
|
|
|
|
|
videoTransceiver: null,
|
|
|
|
|
muted: false,
|
|
|
|
|
cameraEnabled: false,
|
|
|
|
|
videoEverActivated: false,
|
|
|
|
|
connectionRouteLabel: '',
|
|
|
|
|
debugMode: true,
|
|
|
|
|
debugRunId: String(runId || '').trim(),
|
|
|
|
|
debugRole: 'initiator',
|
|
|
|
|
pendingRemoteIceCandidates: [],
|
|
|
|
|
pendingNegotiationReason: '',
|
|
|
|
|
initialOfferInProgress: false,
|
|
|
|
|
initialOfferSent: false,
|
|
|
|
|
timelineEvents: [],
|
|
|
|
@@ -2063,7 +2489,7 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
export async function startOutgoingCall(peerLogin, options = {}) {
|
|
|
|
|
const cleanPeer = String(peerLogin || '').trim();
|
|
|
|
|
if (!cleanPeer) return;
|
|
|
|
|
|
|
|
|
@@ -2073,6 +2499,7 @@ export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const callId = makeCallId();
|
|
|
|
|
const callMode = normalizeCallMode(options?.mode || CALL_MODE_AUDIO);
|
|
|
|
|
const preflightTimeoutMs = resolveCallPreflightTimeoutMs();
|
|
|
|
|
const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false });
|
|
|
|
|
if (!preflightOk) {
|
|
|
|
@@ -2082,6 +2509,9 @@ export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
const call = {
|
|
|
|
|
callId,
|
|
|
|
|
peerLogin: cleanPeer,
|
|
|
|
|
callMode,
|
|
|
|
|
videoTransportMode: getVideoTransportModeForCallMode(callMode),
|
|
|
|
|
autoEnableCameraOnConnect: isInstantVideoMode(callMode),
|
|
|
|
|
direction: 'out',
|
|
|
|
|
phase: 'searching',
|
|
|
|
|
statusText: 'Ищем пользователя…',
|
|
|
|
@@ -2095,17 +2525,20 @@ export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
remoteMediaStream: null,
|
|
|
|
|
audioSenders: [],
|
|
|
|
|
videoSender: null,
|
|
|
|
|
videoTransceiver: null,
|
|
|
|
|
muted: false,
|
|
|
|
|
cameraEnabled: false,
|
|
|
|
|
cameraEnabled: isInstantVideoMode(callMode),
|
|
|
|
|
videoEverActivated: false,
|
|
|
|
|
connectionRouteLabel: '',
|
|
|
|
|
reconnectInProgress: false,
|
|
|
|
|
reconnectAttempts: 0,
|
|
|
|
|
debugMode: false,
|
|
|
|
|
debugRunId: '',
|
|
|
|
|
debugRole: '',
|
|
|
|
|
pendingRemoteIceCandidates: [],
|
|
|
|
|
initialOfferInProgress: false,
|
|
|
|
|
initialOfferSent: false,
|
|
|
|
|
pendingRemoteIceCandidates: [],
|
|
|
|
|
pendingNegotiationReason: '',
|
|
|
|
|
initialOfferInProgress: false,
|
|
|
|
|
initialOfferSent: false,
|
|
|
|
|
timelineEvents: [],
|
|
|
|
|
timelineSeq: 0,
|
|
|
|
|
timelineBaseMs: nowMs(),
|
|
|
|
@@ -2124,7 +2557,7 @@ export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
}, 35000);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
|
|
|
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE, buildInviteData({ mode: callMode }));
|
|
|
|
|
recordCallTimeline(
|
|
|
|
|
call,
|
|
|
|
|
'invite_broadcast_ok',
|
|
|
|
@@ -2142,7 +2575,7 @@ export async function startOutgoingCall(peerLogin) {
|
|
|
|
|
const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true });
|
|
|
|
|
if (recovered) {
|
|
|
|
|
try {
|
|
|
|
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE);
|
|
|
|
|
call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE, buildInviteData({ mode: callMode }));
|
|
|
|
|
if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') {
|
|
|
|
|
setStatus(call, 'Вызываем…', 'ringing');
|
|
|
|
|
}
|
|
|
|
@@ -2193,6 +2626,9 @@ export async function acceptIncomingCall() {
|
|
|
|
|
const call = getActiveCall();
|
|
|
|
|
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
|
|
|
|
recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`);
|
|
|
|
|
if (call.autoEnableCameraOnConnect) {
|
|
|
|
|
call.cameraEnabled = true;
|
|
|
|
|
}
|
|
|
|
|
call.phase = 'connecting';
|
|
|
|
|
setStatus(call, 'Соединяем…', 'connecting');
|
|
|
|
|
cleanupTimers(call);
|
|
|
|
@@ -2347,10 +2783,24 @@ export async function handleIncomingCallSignal(evt) {
|
|
|
|
|
await pc.setLocalDescription({ type: 'rollback' });
|
|
|
|
|
}
|
|
|
|
|
await pc.setRemoteDescription(incomingOffer);
|
|
|
|
|
if (hasVideoSectionInSdp(incomingOffer?.sdp || '')) {
|
|
|
|
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
|
|
|
|
ensureReservedVideoTransceiver(call);
|
|
|
|
|
if (!call.videoSender) {
|
|
|
|
|
await ensureVideoSenderSlot(call, { sourceTrack: ensureBlackVideoTrack(call), profile: 'placeholder' });
|
|
|
|
|
}
|
|
|
|
|
if (call.autoEnableCameraOnConnect && call.cameraEnabled) {
|
|
|
|
|
try {
|
|
|
|
|
await applyCameraState(call);
|
|
|
|
|
} catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
syncRemoteReceiverVideoTracks(call);
|
|
|
|
|
await flushPendingIceCandidates(call);
|
|
|
|
|
const answer = await pc.createAnswer();
|
|
|
|
|
await pc.setLocalDescription(answer);
|
|
|
|
|
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
|
|
|
|
refreshVideoSlotReady(call);
|
|
|
|
|
setStatus(call, 'Соединяем…', 'connecting');
|
|
|
|
|
notifyCallState();
|
|
|
|
|
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
|
|
|
@@ -2382,7 +2832,13 @@ export async function handleIncomingCallSignal(evt) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
|
|
|
|
if (hasVideoSectionInSdp(pc.remoteDescription?.sdp || '')) {
|
|
|
|
|
call.videoTransportMode = VIDEO_TRANSPORT_SLOT;
|
|
|
|
|
ensureReservedVideoTransceiver(call);
|
|
|
|
|
}
|
|
|
|
|
syncRemoteReceiverVideoTracks(call);
|
|
|
|
|
await flushPendingIceCandidates(call);
|
|
|
|
|
refreshVideoSlotReady(call);
|
|
|
|
|
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
|
|
|
|
|
setStatus(call, 'Соединяем…', 'connecting');
|
|
|
|
|
notifyCallState();
|
|
|
|
|