import { addAppLogEntry, addSignedMessageToChat, authService, authorizeSession, state } from '../state.js'; import { buildDmCallTechBlock } from './dm-tech-blocks.js'; const TYPES = { INVITE: 100, RINGING: 110, ACCEPT: 120, DECLINE_BUSY: 130, TIMEOUT: 140, HANGUP: 150, CONNECT_START: 170, OFFER: 200, ANSWER: 210, ICE: 220, }; const SIGNAL_TARGET_SINGLE = 'single_session'; const SIGNAL_TARGET_ALL = 'all_sessions'; const CALL_SIGNAL_TYPES = Object.freeze({ [TYPES.INVITE]: 'call_invite', [TYPES.RINGING]: 'call_ringing', [TYPES.ACCEPT]: 'call_accept', [TYPES.DECLINE_BUSY]: 'call_decline_busy', [TYPES.TIMEOUT]: 'call_timeout', [TYPES.HANGUP]: 'call_hangup', [TYPES.CONNECT_START]: 'call_connect_start', [TYPES.OFFER]: 'call_offer', [TYPES.ANSWER]: 'call_answer', [TYPES.ICE]: 'call_ice', }); const calls = new Map(); const callStateListeners = new Set(); let activeCallId = ''; let debugReporter = null; let audioContext = null; let toneTimerId = null; let toneName = ''; let toneFlip = false; const DEFAULT_ICE_SERVERS = Object.freeze([ { urls: 'stun:stun.l.google.com:19302' }, ]); const CALL_SUMMARY_MIN_TOTAL_MS = 5000; const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000; const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000; const INCOMING_CONNECT_TIMEOUT_MS = 20_000; 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() { return Date.now(); } function isMobileBrowser() { const uaDataMobile = navigator?.userAgentData?.mobile; if (typeof uaDataMobile === 'boolean') return uaDataMobile; const ua = String(navigator?.userAgent || ''); return /Android|iPhone|iPad|iPod|Mobile/i.test(ua); } function canRouteAudioOutputInBrowser() { const mediaProto = globalThis?.HTMLMediaElement?.prototype; return Boolean( window.isSecureContext && isMobileBrowser() && mediaProto && typeof mediaProto.setSinkId === 'function' && navigator?.mediaDevices && typeof navigator.mediaDevices.selectAudioOutput === 'function' ); } function sanitizeTimelineText(value) { return String(value || '') .replace(/\s+/g, ' ') .replace(/[,]/g, ';') .trim(); } function getSignalTypeName(type) { const normalized = Number(type); if (normalized === TYPES.INVITE) return 'invite'; if (normalized === TYPES.RINGING) return 'ringing'; if (normalized === TYPES.ACCEPT) return 'accept'; if (normalized === TYPES.DECLINE_BUSY) return 'decline_busy'; if (normalized === TYPES.TIMEOUT) return 'timeout'; if (normalized === TYPES.HANGUP) return 'hangup'; if (normalized === TYPES.CONNECT_START) return 'connect_start'; if (normalized === TYPES.OFFER) return 'offer'; if (normalized === TYPES.ANSWER) return 'answer'; if (normalized === TYPES.ICE) return 'ice'; return `type_${String(type || '')}`; } function buildConnectStartData(winnerSessionId) { return `session=${String(winnerSessionId || '').trim()}`; } function parseConnectStartWinnerSessionId(data = '') { const raw = String(data || '').trim(); if (!raw) return ''; if (raw.startsWith('session=')) return raw.slice('session='.length).trim(); if (raw.startsWith('winnerSessionId=')) return raw.slice('winnerSessionId='.length).trim(); return ''; } function getCallSignalType(type) { return CALL_SIGNAL_TYPES[Number(type)] || ''; } function encodeCallSignalData(callId, type, data = '') { return JSON.stringify({ callId: String(callId || '').trim(), type: Number(type), data: String(data || ''), }); } function decodeCallSignalData(raw) { try { const parsed = JSON.parse(String(raw || '{}')); return { callId: String(parsed?.callId || '').trim(), type: Number(parsed?.type), data: String(parsed?.data || ''), }; } catch { return { callId: '', type: Number.NaN, data: '', }; } } function ensureCallTimeline(call) { if (!call) return; if (!Array.isArray(call.timelineEvents)) call.timelineEvents = []; if (!Number.isFinite(call.timelineSeq)) call.timelineSeq = 0; if (!Number.isFinite(call.timelineBaseMs) || call.timelineBaseMs <= 0) { call.timelineBaseMs = Number(call.startedAtMs || nowMs()); } } function recordCallTimeline(call, stage, details = '', level = 'info') { if (!call) return; ensureCallTimeline(call); const ts = nowMs(); const deltaMs = Math.max(0, ts - Number(call.timelineBaseMs || ts)); const cleanStage = sanitizeTimelineText(stage) || 'unknown'; const cleanDetails = sanitizeTimelineText(details); call.timelineSeq += 1; call.timelineEvents.push({ seq: call.timelineSeq, ts, deltaMs, stage: cleanStage, details: cleanDetails, }); if (call.timelineEvents.length > MAX_TIMELINE_ENTRIES) { call.timelineEvents.splice(0, call.timelineEvents.length - MAX_TIMELINE_ENTRIES); } addAppLogEntry({ level, source: 'call', message: `[${call.callId || '?'}] +${deltaMs}ms ${cleanStage}`, details: cleanDetails || buildCallFactsJson(call), }); } function buildTimelineSummary(call) { const events = Array.isArray(call?.timelineEvents) ? call.timelineEvents : []; if (!events.length) return ''; return events .map((entry) => { const head = `+${Number(entry?.deltaMs || 0)}:${sanitizeTimelineText(entry?.stage || '')}`; const tail = sanitizeTimelineText(entry?.details || ''); return tail ? `${head}(${tail})` : head; }) .join('|') .slice(0, 1800); } function setRemoteSessionId(call, sessionId, reason = '') { if (!call) return; const next = String(sessionId || '').trim(); if (!next) return; const prev = String(call.remoteSessionId || '').trim(); if (prev === next) { recordCallTimeline(call, 'remote_session_confirmed', `session=${next}; reason=${reason || 'same'}`); return; } call.remoteSessionId = next; recordCallTimeline(call, 'remote_session_selected', `from=${prev || '-'}; to=${next}; reason=${reason || 'unknown'}`); } function resolveCallPreflightTimeoutMs() { const configured = Number(state?.entrySettings?.callPreflightTimeoutMs || 6000); return Math.max(1000, Math.min(20000, Number.isFinite(configured) ? configured : 6000)); } function isSessionReadyForCall() { const wsOpen = Boolean(authService?.ws?.ws && authService.ws.ws.readyState === WebSocket.OPEN); const hasSession = Boolean(state?.session?.isAuthorized && state?.session?.login && state?.session?.sessionId); return wsOpen && hasSession; } async function withTimeout(promise, timeoutMs, timeoutMessage = 'timeout') { let timerId = 0; try { return await Promise.race([ promise, new Promise((_, reject) => { timerId = window.setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs); }), ]); } finally { if (timerId) window.clearTimeout(timerId); } } async function ensureSessionForCall({ timeoutMs, force = false } = {}) { if (!force && isSessionReadyForCall()) return true; const login = String(state?.session?.login || '').trim(); const sessionId = String(state?.session?.sessionId || '').trim(); if (!login || !sessionId) return false; try { await withTimeout(authService.ws.open(), timeoutMs, 'call_preflight_ws_timeout'); const resumed = await withTimeout(authService.resumeSession(login, sessionId), timeoutMs, 'call_preflight_resume_timeout'); authorizeSession(resumed); return true; } catch { return false; } } function makeCallId() { return `${Date.now()}${Math.floor(Math.random() * 1_000_000_000)}`; } function getCall(callId) { return calls.get(callId) || null; } function getActiveCall() { if (!activeCallId) return null; return getCall(activeCallId); } function toErrorText(error) { return error?.message || String(error || 'unknown'); } function formatDuration(ms) { const totalSec = Math.max(0, Math.round(Number(ms || 0) / 1000)); const sec = totalSec % 60; const totalMin = Math.floor(totalSec / 60); const min = totalMin % 60; const hours = Math.floor(totalMin / 60); if (hours > 0) return `${hours}ч ${min}м ${sec}с`; if (min > 0) return `${min}м ${sec}с`; return `${sec}с`; } function isInviteUndelivered(call) { const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0); const pushDelivered = Number( call?.inviteDelivery?.deliveredWebPushSessions || call?.inviteDelivery?.deliveredFcmSessions || 0 ); return (wsDelivered + pushDelivered) <= 0; } function getInviteDeliveredCount(call) { const wsDelivered = Number(call?.inviteDelivery?.deliveredWsSessions || 0); const pushDelivered = Number( call?.inviteDelivery?.deliveredWebPushSessions || call?.inviteDelivery?.deliveredFcmSessions || 0 ); return wsDelivered + pushDelivered; } function buildOutgoingCallSummaryText(call, summaryCode) { if (!call || call.direction !== 'out') return ''; if (summaryCode === 'completed') { return buildDmCallTechBlock({ status: 'completed', durationSec: Math.round(Math.max(0, nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())) / 1000), }); } if (summaryCode === 'busy') { return buildDmCallTechBlock({ status: 'failed', reason: 'busy' }); } if (summaryCode === 'declined') { return buildDmCallTechBlock({ status: 'failed', reason: 'declined' }); } if (summaryCode === 'no_answer') { if (isInviteUndelivered(call)) { return buildDmCallTechBlock({ status: 'failed', reason: 'offline' }); } return buildDmCallTechBlock({ status: 'failed', reason: 'no_answer' }); } if (summaryCode === 'error') { return buildDmCallTechBlock({ status: 'failed', reason: 'connect_failed' }); } return ''; } async function applyLocalOutgoingDmCopy(peerLogin, result) { const localOutgoingBlobB64 = String(result?.localOutgoingBlobB64 || '').trim(); if (!peerLogin || !localOutgoingBlobB64) return false; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); const decrypted = await authService.decryptSignedMessageContent({ parsed, login: state.session.login, storagePwd: state.session.storagePwdInMemory, }); addSignedMessageToChat({ chatId: peerLogin, messageKey: String(result?.outgoingKey || parsed?.messageKey || ''), baseKey: String(result?.baseKey || result?.localBaseKey || parsed?.baseKey || ''), from: 'out', text: String(decrypted?.text || ''), messageType: Number(parsed?.messageType || 2), unread: false, rawBlobB64: localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), deleted: Boolean(parsed?.deleted), }); return true; } catch { return false; } } async function sendOutgoingCallSummaryDm(call, summaryCode) { if (!call || call.direction !== 'out' || !call.peerLogin) return; const totalMs = Math.max(0, nowMs() - Number(call.startedAtMs || nowMs())); if (totalMs < CALL_SUMMARY_MIN_TOTAL_MS) return; const text = buildOutgoingCallSummaryText(call, summaryCode); if (!text) return; const login = String(state?.session?.login || '').trim(); const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); if (!login || !storagePwd) return; try { const result = await authService.sendDirectMessage({ login, toLogin: call.peerLogin, text, storagePwd, }); await applyLocalOutgoingDmCopy(call.peerLogin, result); } catch (error) { await emitDebug(call, 'warn', 'call_summary_dm_failed', toErrorText(error)); } } function cloneDefaultIceServers() { return DEFAULT_ICE_SERVERS.map((row) => ({ ...row })); } function parseIceUrls(raw) { if (Array.isArray(raw)) { return raw .map((item) => String(item || '').trim()) .filter((item) => item.length > 0); } const single = String(raw || '').trim(); if (!single) return []; return [single]; } function uniqueUrls(urls = []) { const out = []; const seen = new Set(); urls.forEach((url) => { const clean = String(url || '').trim(); if (!clean || seen.has(clean)) return; seen.add(clean); out.push(clean); }); return out; } function parseTurnHostFromUrl(rawUrl) { const value = String(rawUrl || '').trim(); if (!value) return ''; const noProto = value.replace(/^turns?:/i, ''); const noQuery = noProto.split('?')[0]; const noPath = noQuery.split('/')[0]; const hostPort = noPath.replace(/^\/\//, '').trim(); if (!hostPort) return ''; if (hostPort.startsWith('[')) { const end = hostPort.indexOf(']'); if (end > 1) return hostPort.slice(1, end); return ''; } const idx = hostPort.indexOf(':'); return (idx >= 0 ? hostPort.slice(0, idx) : hostPort).trim(); } async function resolveIceServers(call) { try { const payload = await authService.getCallIceConfig(); const stunUrls = uniqueUrls(parseIceUrls(payload?.stunUrls)); const turnUrls = uniqueUrls(parseIceUrls(payload?.turnUrls)); const turnUsername = String(payload?.turnUsername || '').trim(); const turnPassword = String(payload?.turnPassword || '').trim(); const turnServers = Array.isArray(payload?.turnServers) ? payload.turnServers : []; const turnHostSet = new Set(); const iceServers = []; if (stunUrls.length > 0) { iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls }); } if (turnServers.length > 0) { turnServers.forEach((item) => { const urls = uniqueUrls(parseIceUrls(item?.urls)); const username = String(item?.username || '').trim(); const password = String(item?.password || '').trim(); if (urls.length === 0 || !username || !password) return; urls.forEach((url) => { const host = parseTurnHostFromUrl(url); if (host) turnHostSet.add(host); }); iceServers.push({ urls: urls.length === 1 ? urls[0] : urls, username, credential: password, }); }); } else if (turnUrls.length > 0 && turnUsername && turnPassword) { turnUrls.forEach((url) => { const host = parseTurnHostFromUrl(url); if (host) turnHostSet.add(host); }); iceServers.push({ urls: turnUrls.length === 1 ? turnUrls[0] : turnUrls, username: turnUsername, credential: turnPassword, }); } if (iceServers.length === 0) { await emitDebug(call, 'warn', 'call_ice_empty_from_server', 'using_default_stun'); return cloneDefaultIceServers(); } if (call) { call.turnHostsConfigured = Array.from(turnHostSet); } await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turnEntries=${Math.max(0, iceServers.length - (stunUrls.length > 0 ? 1 : 0))}`); return iceServers; } catch (error) { await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error)); return cloneDefaultIceServers(); } } async function collectIceCandidateAnalytics(call) { const pc = call?.pc; if (!pc || typeof pc.getStats !== 'function') return {}; try { const stats = await pc.getStats(); const localCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 }; const remoteCounts = { host: 0, srflx: 0, relay: 0, prflx: 0, other: 0 }; const relayLocalAddresses = new Set(); const relayRemoteAddresses = new Set(); const configuredHosts = new Set((call?.turnHostsConfigured || []).map((v) => String(v || '').trim()).filter(Boolean)); const matchedConfiguredHosts = new Set(); let succeededPairsCount = 0; let succeededPairsWithRelayCount = 0; const bump = (bucket, rawType) => { const type = String(rawType || '').trim().toLowerCase(); if (!type) bucket.other += 1; else if (Object.prototype.hasOwnProperty.call(bucket, type)) bucket[type] += 1; else bucket.other += 1; }; const hostOf = (candidate) => { const raw = String(candidate?.ip || candidate?.address || '').trim(); if (!raw) return ''; if (raw.startsWith('[')) { const end = raw.indexOf(']'); if (end > 1) return raw.slice(1, end); } return raw; }; stats.forEach((report) => { if (!report || report.type !== 'local-candidate') return; bump(localCounts, report.candidateType); if (String(report.candidateType || '').toLowerCase() === 'relay') { const addr = String(report.ip || report.address || ''); const port = String(report.port || ''); relayLocalAddresses.add(port ? `${addr}:${port}` : addr); const host = hostOf(report); if (host && configuredHosts.has(host)) matchedConfiguredHosts.add(host); } }); stats.forEach((report) => { if (!report || report.type !== 'remote-candidate') return; bump(remoteCounts, report.candidateType); if (String(report.candidateType || '').toLowerCase() === 'relay') { const addr = String(report.ip || report.address || ''); const port = String(report.port || ''); relayRemoteAddresses.add(port ? `${addr}:${port}` : addr); } }); stats.forEach((report) => { if (!report || report.type !== 'candidate-pair') return; const state = String(report.state || '').toLowerCase(); const ok = report.selected === true || (report.nominated === true && state === 'succeeded') || state === 'succeeded'; if (!ok) return; succeededPairsCount += 1; const local = report.localCandidateId && typeof stats.get === 'function' ? stats.get(report.localCandidateId) : null; const remote = report.remoteCandidateId && typeof stats.get === 'function' ? stats.get(report.remoteCandidateId) : null; const lt = String(local?.candidateType || '').toLowerCase(); const rt = String(remote?.candidateType || '').toLowerCase(); if (lt === 'relay' || rt === 'relay') succeededPairsWithRelayCount += 1; }); return { localCandidatesHost: localCounts.host, localCandidatesSrflx: localCounts.srflx, localCandidatesRelay: localCounts.relay, localCandidatesPrflx: localCounts.prflx, localCandidatesOther: localCounts.other, remoteCandidatesHost: remoteCounts.host, remoteCandidatesSrflx: remoteCounts.srflx, remoteCandidatesRelay: remoteCounts.relay, remoteCandidatesPrflx: remoteCounts.prflx, remoteCandidatesOther: remoteCounts.other, relayLocalCandidatesFound: relayLocalAddresses.size, relayRemoteCandidatesFound: relayRemoteAddresses.size, relayLocalCandidatesAddresses: Array.from(relayLocalAddresses).join('|'), relayRemoteCandidatesAddresses: Array.from(relayRemoteAddresses).join('|'), configuredTurnHosts: Array.from(configuredHosts).join('|'), configuredTurnHostsCount: configuredHosts.size, reachableTurnHostsCount: matchedConfiguredHosts.size, reachableTurnHosts: Array.from(matchedConfiguredHosts).join('|'), turnConfiguredButNotReachedHosts: Array.from(configuredHosts).filter((host) => !matchedConfiguredHosts.has(host)).join('|'), succeededCandidatePairsCount: succeededPairsCount, succeededCandidatePairsWithRelayCount: succeededPairsWithRelayCount, }; } catch { return {}; } } function ensureAudioContext() { if (audioContext) return audioContext; const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; audioContext = new Ctx(); return audioContext; } function playBeep(freq = 440, durationMs = 120, gainValue = 0.08) { const ctx = ensureAudioContext(); if (!ctx) return; if (ctx.state === 'suspended') { void ctx.resume().catch(() => {}); } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = 'sine'; osc.frequency.value = Number(freq) || 440; gain.gain.value = gainValue; osc.connect(gain); gain.connect(ctx.destination); const now = ctx.currentTime; osc.start(now); osc.stop(now + Math.max(0.03, durationMs / 1000)); } function stopTone() { if (toneTimerId) { clearInterval(toneTimerId); toneTimerId = null; } toneName = ''; toneFlip = false; } function startTone(nextToneName) { if (!nextToneName) { stopTone(); return; } if (toneName === nextToneName) return; stopTone(); toneName = nextToneName; if (nextToneName === 'searching') { toneTimerId = window.setInterval(() => { toneFlip = !toneFlip; playBeep(toneFlip ? 920 : 760, 120, 0.07); }, 420); return; } if (nextToneName === 'ringback') { playBeep(425, 900, 0.08); toneTimerId = window.setInterval(() => { playBeep(425, 900, 0.08); }, 4000); return; } if (nextToneName === 'incoming') { const hit = () => { playBeep(830, 180, 0.09); window.setTimeout(() => playBeep(680, 180, 0.09), 240); }; hit(); toneTimerId = window.setInterval(hit, 2000); } } function getCallStateSnapshot() { const call = getActiveCall(); if (!call) return null; if (call.localUiDismissed) return null; const callPhase = String(call.phase || '').trim(); const localVideoTracks = call.localStream?.getVideoTracks?.() || []; const remoteVideoTracks = call.remoteMediaStream?.getVideoTracks?.() || []; const canToggleSpeaker = Boolean( callPhase !== 'incoming' && callPhase !== 'ended' && canRouteAudioOutputInBrowser() && call.remoteAudio ); const canToggleCamera = Boolean( callPhase !== 'incoming' && callPhase !== 'ended' && navigator?.mediaDevices?.getUserMedia && call.pc ); return { callId: call.callId, peerLogin: call.peerLogin || '', direction: call.direction || 'out', phase: callPhase, statusText: call.statusText || '', startedAtMs: Number(call.startedAtMs || 0), connectedAtMs: Number(call.connectedAtMs || 0), muted: Boolean(call.muted), cameraEnabled: Boolean(call.cameraEnabled), speakerEnabled: Boolean(call.speakerEnabled), canToggleSpeaker, canToggleCamera, canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', canHangup: callPhase !== 'ended' && callPhase !== 'incoming', canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting', 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, }; } function notifyCallState() { const snapshot = getCallStateSnapshot(); callStateListeners.forEach((listener) => { try { listener(snapshot); } catch {} }); } function dismissCallUiLocally(call) { if (!call) return; call.localUiDismissed = true; notifyCallState(); } function setStatus(call, statusText, phase = '') { if (!call) return; call.statusText = String(statusText || '').trim(); if (phase) { call.phase = String(phase || '').trim(); } if (call.phase === 'searching') startTone('searching'); else if (call.phase === 'ringing') startTone('ringback'); else if (call.phase === 'incoming') startTone('incoming'); else stopTone(); recordCallTimeline(call, 'status', `phase=${call.phase || ''}; text=${call.statusText || ''}`); void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`); notifyCallState(); } function buildActiveStatusText(call) { const route = String(call?.connectionRouteLabel || '').trim(); 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 setActiveStatus(call) { setStatus(call, buildActiveStatusText(call), 'active'); } function toIsoTs(ts) { const n = Number(ts || 0); if (!Number.isFinite(n) || n <= 0) return ''; try { return new Date(n).toISOString(); } catch { return ''; } } function buildCallFactsJson(call, extra = {}) { const pc = call?.pc || null; const facts = { callId: call?.callId || '', peerLogin: call?.peerLogin || '', remoteSessionId: call?.remoteSessionId || '', direction: call?.direction || '', phase: call?.phase || '', statusText: call?.statusText || '', startedAtMs: Number(call?.startedAtMs || 0), startedAtIso: toIsoTs(call?.startedAtMs), connectedAtMs: Number(call?.connectedAtMs || 0), connectedAtIso: toIsoTs(call?.connectedAtMs), routeLabel: call?.connectionRouteLabel || '', routeDetails: call?.connectionRouteDetails || '', pcConnectionState: pc?.connectionState || '', pcIceConnectionState: pc?.iceConnectionState || '', pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0, timeline: buildTimelineSummary(call), ...extra, }; try { return JSON.stringify(facts); } catch { return JSON.stringify({ callId: call?.callId || '', serializeError: true }); } } function buildCallFactsLine(call, extra = {}) { const pc = call?.pc || null; const facts = { callId: call?.callId || '', peerLogin: call?.peerLogin || '', remoteSessionId: call?.remoteSessionId || '', direction: call?.direction || '', phase: call?.phase || '', statusText: call?.statusText || '', startedAtMs: Number(call?.startedAtMs || 0), startedAtIso: toIsoTs(call?.startedAtMs), connectedAtMs: Number(call?.connectedAtMs || 0), connectedAtIso: toIsoTs(call?.connectedAtMs), routeLabel: call?.connectionRouteLabel || '', routeDetails: call?.connectionRouteDetails || '', pcConnectionState: pc?.connectionState || '', pcIceConnectionState: pc?.iceConnectionState || '', pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0, localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)), timeline: buildTimelineSummary(call), ...extra, }; return Object.entries(facts) .map(([k, v]) => `${k}=${String(v ?? '').replace(/,/g, ';')}`) .join(', '); } function buildMediaSnapshot(call) { const localStream = call?.localStream || null; const localTracks = localStream?.getTracks?.() || []; const remoteAudio = call?.remoteAudio || null; const remoteStream = call?.remoteMediaStream || remoteAudio?.srcObject || null; const remoteTracks = remoteStream?.getTracks?.() || []; return { hasLocalStream: Boolean(localStream), localTrackCount: localTracks.length, localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0, localVideoTracksCount: localStream?.getVideoTracks?.()?.length || 0, localTrackStates: localTracks.map((track) => ({ kind: String(track?.kind || ''), enabled: Boolean(track?.enabled), muted: Boolean(track?.muted), readyState: String(track?.readyState || ''), label: String(track?.label || ''), })), hasRemoteAudio: Boolean(remoteAudio), hasRemoteAudioSrcObject: Boolean(remoteStream), remoteTrackCount: remoteTracks.length, remoteTrackStates: remoteTracks.map((track) => ({ kind: String(track?.kind || ''), enabled: Boolean(track?.enabled), muted: Boolean(track?.muted), readyState: String(track?.readyState || ''), label: String(track?.label || ''), })), }; } function summarizeMediaSnapshot(snapshot) { if (!snapshot) return ''; const localSummary = Array.isArray(snapshot.localTrackStates) ? snapshot.localTrackStates.map((track) => ( `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` )).join('|') : ''; const remoteSummary = Array.isArray(snapshot.remoteTrackStates) ? snapshot.remoteTrackStates.map((track) => ( `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` )).join('|') : ''; return [ `localStream=${snapshot.hasLocalStream ? 1 : 0}`, `localTracks=${Number(snapshot.localTrackCount || 0)}`, `localAudio=${Number(snapshot.localAudioTracksCount || 0)}`, `localVideo=${Number(snapshot.localVideoTracksCount || 0)}`, `localStates=${localSummary || '-'}`, `remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`, `remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`, `remoteTracks=${Number(snapshot.remoteTrackCount || 0)}`, `remoteStates=${remoteSummary || '-'}`, ].join(';'); } function getCallDiagnosticsContext(call) { const pc = call?.pc || null; const nav = typeof navigator !== 'undefined' ? navigator : null; const conn = nav?.connection || nav?.mozConnection || nav?.webkitConnection || null; const permissionsApiAvailable = typeof nav?.permissions?.query === 'function'; const mediaDevicesAvailable = Boolean(nav?.mediaDevices?.getUserMedia); const online = typeof nav?.onLine === 'boolean' ? nav.onLine : null; const visibilityState = typeof document !== 'undefined' ? String(document.visibilityState || '') : ''; const pageFocused = typeof document !== 'undefined' && typeof document.hasFocus === 'function' ? Boolean(document.hasFocus()) : false; const localTracks = call?.localStream?.getTracks?.() || []; const localAudioTracks = call?.localStream?.getAudioTracks?.() || []; const localVideoTracks = call?.localStream?.getVideoTracks?.() || []; const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length; const enabledLocalVideoTracks = localVideoTracks.filter((track) => track?.enabled).length; const transceiversCount = pc?.getTransceivers?.()?.length || 0; const sendersCount = pc?.getSenders?.()?.length || 0; const receiversCount = pc?.getReceivers?.()?.length || 0; const iceGatheringState = pc?.iceGatheringState || ''; const currentLocalDescType = pc?.localDescription?.type || ''; const currentRemoteDescType = pc?.remoteDescription?.type || ''; const mediaSnapshot = buildMediaSnapshot(call); return { remoteSessionIdPresent: Boolean(call?.remoteSessionId), callExistsInStore: calls.has(String(call?.callId || '')), browserOnline: online === null ? '' : String(online), documentVisibilityState: visibilityState, pageFocused, userAgent: typeof nav?.userAgent === 'string' ? nav.userAgent : '', platform: typeof nav?.platform === 'string' ? nav.platform : '', language: typeof nav?.language === 'string' ? nav.language : '', permissionsApiAvailable, mediaDevicesApiAvailable: mediaDevicesAvailable, connectionType: String(conn?.type || ''), effectiveConnectionType: String(conn?.effectiveType || ''), networkRttMs: Number(conn?.rtt || 0), networkDownlinkMbps: Number(conn?.downlink || 0), saveData: conn?.saveData === true, localTrackCount: localTracks.length, localAudioTracksCount: localAudioTracks.length, localAudioTracksEnabledCount: enabledLocalAudioTracks, localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'), localVideoTracksCount: localVideoTracks.length, localVideoTracksEnabledCount: enabledLocalVideoTracks, localVideoTrackLabels: localVideoTracks.map((t) => String(t?.label || '')).join('|'), localTrackStates: summarizeMediaSnapshot(mediaSnapshot), hasPeerConnection: Boolean(pc), pcConnectionState: pc?.connectionState || '', pcIceConnectionState: pc?.iceConnectionState || '', pcIceGatheringState: iceGatheringState, pcSignalingState: pc?.signalingState || '', pcCanTrickleIceCandidates: pc?.canTrickleIceCandidates === null || pc?.canTrickleIceCandidates === undefined ? '' : String(pc?.canTrickleIceCandidates), localDescriptionType: currentLocalDescType, remoteDescriptionType: currentRemoteDescType, pcTransceiversCount: transceiversCount, pcSendersCount: sendersCount, pcReceiversCount: receiversCount, }; } async function sendCallDeliveryReport(call, eventType, eventCode, reason = '', extraFacts = {}) { if (!call || !authService || typeof authService.sendCallDeliveryReport !== 'function') return; try { const diagnostics = getCallDiagnosticsContext(call); const valueLine = buildCallFactsLine(call, { eventType: String(eventType || '').trim(), eventCode: String(eventCode || '').trim(), reason: String(reason || '').trim(), reportedAtMs: nowMs(), reportedAtIso: toIsoTs(nowMs()), timelineEntriesCount: Array.isArray(call?.timelineEvents) ? call.timelineEvents.length : 0, ...diagnostics, ...extraFacts, }); await authService.sendCallDeliveryReport({ type: String(eventType || '').trim(), value: valueLine, }); } catch {} } function cleanupTimers(call) { if (call.timers?.ack10s) clearTimeout(call.timers.ack10s); if (call.timers?.total35s) clearTimeout(call.timers.total35s); if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s); if (call.timers?.incomingConnect20s) clearTimeout(call.timers.incomingConnect20s); if (call.timers?.transportProbe) clearInterval(call.timers.transportProbe); call.timers.transportProbe = null; } function scheduleOutgoingAckTimeout(call) { if (!call?.timers) return; if (call.timers.ack10s) { clearTimeout(call.timers.ack10s); call.timers.ack10s = null; } const deliveredCount = getInviteDeliveredCount(call); const timeoutMs = deliveredCount > 0 ? OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS : OUTGOING_NO_ACK_TIMEOUT_MS; const debugReason = deliveredCount > 0 ? 'no_ack_after_delivery_25s' : 'no_ack_10s'; recordCallTimeline(call, 'ack_timeout_scheduled', `timeoutMs=${timeoutMs}; delivered=${deliveredCount}; reason=${debugReason}`); call.timers.ack10s = setTimeout(() => { if (!calls.has(call.callId)) return; if (call.phase === 'searching' || call.phase === 'ringing') { recordCallTimeline(call, 'ack_timeout_fired', `phase=${call.phase || ''}; reason=${debugReason}`, 'warn'); void finalizeCall(call, { localReasonCode: 'no_answer', debugReason }); } }, timeoutMs); } function scheduleIncomingConnectTimeout(call) { if (!call?.timers) return; if (call.timers.incomingConnect20s) { clearTimeout(call.timers.incomingConnect20s); } recordCallTimeline(call, 'incoming_connect_timeout_scheduled', `timeoutMs=${INCOMING_CONNECT_TIMEOUT_MS}`); call.timers.incomingConnect20s = setTimeout(() => { if (!calls.has(call.callId)) return; if (call.phase !== 'connecting') return; recordCallTimeline(call, 'incoming_connect_timeout_fired', `phase=${call.phase || ''}`, 'warn'); void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_connect_timeout_20s', }); }, INCOMING_CONNECT_TIMEOUT_MS); } async function flushPendingIceCandidates(call) { if (!call?.pc) return; const pending = Array.isArray(call.pendingRemoteIceCandidates) ? call.pendingRemoteIceCandidates : []; if (!pending.length) return; call.pendingRemoteIceCandidates = []; for (const candidate of pending) { try { await call.pc.addIceCandidate(new RTCIceCandidate(candidate)); await emitDebug(call, 'info', 'ice_processed_from_queue', 'candidate added'); } catch (error) { await emitDebug(call, 'warn', 'ice_process_failed_from_queue', toErrorText(error)); } } } async function closeMedia(call) { const pc = call?.pc || null; const beforeSnapshot = buildMediaSnapshot(call); recordCallTimeline(call, 'media_close_begin', summarizeMediaSnapshot(beforeSnapshot)); try { const senders = pc?.getSenders?.() || []; senders.forEach((sender) => { try { if (typeof sender?.replaceTrack === 'function') sender.replaceTrack(null); } catch {} }); } catch {} try { const transceivers = pc?.getTransceivers?.() || []; transceivers.forEach((tr) => { try { tr?.stop?.(); } catch {} }); } catch {} try { if (pc) { pc.ontrack = null; pc.onicecandidate = null; pc.onconnectionstatechange = null; pc.oniceconnectionstatechange = null; pc.onsignalingstatechange = null; pc.onicegatheringstatechange = null; } } catch {} try { call.localStream?.getTracks?.()?.forEach((track) => { try { track.enabled = false; } catch {} try { track.stop(); } catch {} }); } catch {} try { if (call.remoteAudio) { const remoteTracks = call.remoteMediaStream?.getTracks?.() || call.remoteAudio.srcObject?.getTracks?.() || []; remoteTracks.forEach((track) => { try { track.enabled = false; } catch {} try { track.stop(); } catch {} }); try { call.remoteAudio.pause?.(); } catch {} try { call.remoteAudio.srcObject = null; } catch {} try { call.remoteAudio.removeAttribute?.('src'); } catch {} try { call.remoteAudio.load?.(); } catch {} call.remoteAudio = null; } } catch {} try { pc?.close?.(); } catch {} call.pc = null; call.localStream = null; call.localVideoTrack = null; call.videoSender = null; call.remoteMediaStream = null; call.audioSenders = []; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; call.pendingRemoteIceCandidates = []; const afterSnapshot = buildMediaSnapshot(call); recordCallTimeline(call, 'media_close_end', summarizeMediaSnapshot(afterSnapshot)); await emitDebug( call, 'info', 'media_closed', `before=${summarizeMediaSnapshot(beforeSnapshot)} | after=${summarizeMediaSnapshot(afterSnapshot)}`, ); } function stopReconnectFlow(call) { if (!call?.timers) return; if (call.timers.reconnectStep) { clearTimeout(call.timers.reconnectStep); call.timers.reconnectStep = null; } if (call.timers.reconnectDeadline) { clearTimeout(call.timers.reconnectDeadline); call.timers.reconnectDeadline = null; } call.reconnectInProgress = false; call.reconnectAttempts = 0; } async function detectConnectionRoute(call) { const pc = call?.pc; if (!pc || typeof pc.getStats !== 'function') { return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' }; } try { const stats = await pc.getStats(); let selectedPair = null; stats.forEach((report) => { if (selectedPair) return; if (report.type !== 'transport') return; if (!report.selectedCandidatePairId) return; const pair = typeof stats.get === 'function' ? stats.get(report.selectedCandidatePairId) : null; if (pair) selectedPair = pair; }); if (!selectedPair) { stats.forEach((report) => { if (selectedPair) return; if (report.type === 'candidate-pair' && report.selected) selectedPair = report; }); } if (!selectedPair) { stats.forEach((report) => { if (selectedPair) return; if (report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded') { selectedPair = report; } }); } if (!selectedPair) return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' }; const local = selectedPair.localCandidateId && typeof stats.get === 'function' ? stats.get(selectedPair.localCandidateId) : null; const remote = selectedPair.remoteCandidateId && typeof stats.get === 'function' ? stats.get(selectedPair.remoteCandidateId) : null; const localType = String(local?.candidateType || '').trim().toLowerCase(); const remoteType = String(remote?.candidateType || '').trim().toLowerCase(); const localIp = String(local?.ip || local?.address || ''); const remoteIp = String(remote?.ip || remote?.address || ''); const localPort = String(local?.port || ''); const remotePort = String(remote?.port || ''); const relayProto = String(local?.relayProtocol || remote?.relayProtocol || ''); const turnCandidateAddress = localType === 'relay' ? `${localIp}${localPort ? `:${localPort}` : ''}` : (remoteType === 'relay' ? `${remoteIp}${remotePort ? `:${remotePort}` : ''}` : ''); const details = `local=${localType || '-'}(${localIp || '-'}${localPort ? `:${localPort}` : ''}); remote=${remoteType || '-'}(${remoteIp || '-'}${remotePort ? `:${remotePort}` : ''})`; if (localType === 'relay' || remoteType === 'relay') { const label = turnCandidateAddress ? `через TURN (${turnCandidateAddress})` : (relayProto ? `через TURN (${relayProto})` : 'через TURN'); return { label, details, localIp, remoteIp, turnCandidateAddress }; } if (localType || remoteType) { const sameLan = localIp && remoteIp && ( (localIp.startsWith('10.') && remoteIp.startsWith('10.')) || (localIp.startsWith('192.168.') && remoteIp.startsWith('192.168.')) || (localIp.startsWith('172.16.') && remoteIp.startsWith('172.16.')) ); return { label: sameLan ? 'напрямую в локальной сети' : 'напрямую через интернет', details, localIp, remoteIp, turnCandidateAddress: '', }; } return { label: '', details, localIp, remoteIp, turnCandidateAddress: '' }; } catch { return { label: '', details: '', localIp: '', remoteIp: '', turnCandidateAddress: '' }; } } function startTransportProbe(call) { if (!call?.timers || !call.pc) return; if (call.timers.transportProbe) { clearInterval(call.timers.transportProbe); call.timers.transportProbe = null; } const refresh = async () => { if (!calls.has(call.callId) || call.phase === 'ended') return; if (!call.pc || call.pc.connectionState !== 'connected') return; const route = await detectConnectionRoute(call); if (!route.label || route.label === call.connectionRouteLabel) return; call.connectionRouteLabel = route.label; call.connectionRouteDetails = route.details || ''; setActiveStatus(call); await emitDebug(call, 'info', 'peer_connection_route', route.details || route.label); }; void refresh(); call.timers.transportProbe = window.setInterval(() => { void refresh(); }, 4000); } function startReconnectFlow(call, reason = 'disconnected') { if (!call || call.phase === 'ended') return; if (!call.connectedAtMs) return; if (!call.pc) return; if (call.reconnectInProgress) return; call.reconnectInProgress = true; call.reconnectAttempts = 0; recordCallTimeline(call, 'reconnect_start', `reason=${reason}`); setStatus(call, 'Связь прервалась. Переподключаем…', 'reconnecting'); void emitDebug(call, 'warn', 'peer_connection_reconnect_start', `reason=${reason}`); const maxAttempts = 6; const attemptDelayMs = 2500; const totalDeadlineMs = 17000; call.timers.reconnectDeadline = setTimeout(() => { if (!calls.has(call.callId) || call.phase === 'ended') return; if (call.pc?.connectionState === 'connected') return; stopReconnectFlow(call); void finalizeCall(call, { localReasonCode: 'error', debugReason: 'reconnect_timeout' }); }, totalDeadlineMs); const runAttempt = async () => { if (!calls.has(call.callId) || call.phase === 'ended') return; if (!call.pc || call.pc.connectionState === 'connected') { stopReconnectFlow(call); return; } call.reconnectAttempts += 1; recordCallTimeline(call, 'reconnect_attempt', `attempt=${call.reconnectAttempts}`); try { const offer = await call.pc.createOffer({ iceRestart: true }); await call.pc.setLocalDescription(offer); await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); await emitDebug(call, 'info', 'peer_connection_reconnect_offer_sent', `attempt=${call.reconnectAttempts}`); } catch (error) { await emitDebug(call, 'warn', 'peer_connection_reconnect_offer_failed', `attempt=${call.reconnectAttempts}; error=${toErrorText(error)}`); } if (call.pc?.connectionState === 'connected') { stopReconnectFlow(call); return; } if (call.reconnectAttempts >= maxAttempts) { stopReconnectFlow(call); await finalizeCall(call, { localReasonCode: 'error', debugReason: 'reconnect_attempts_exhausted' }); return; } call.timers.reconnectStep = setTimeout(() => { void runAttempt(); }, attemptDelayMs); }; void runAttempt(); } async function finalizeCall(call, { localReasonCode = 'error', debugReason = '', notifyRemoteHangup = false, suppressRemoteSignal = false, suppressReports = false, suppressSummary = false, } = {}) { if (!call) return; recordCallTimeline( call, 'finalize_begin', `reason=${localReasonCode || ''}; debug=${debugReason || ''}; phase=${call.phase || ''}`, String(localReasonCode || '') === 'completed' ? 'info' : 'warn', ); const diagnosticsBeforeClose = getCallDiagnosticsContext(call); cleanupTimers(call); stopReconnectFlow(call); stopTone(); if (String(debugReason || '') === 'remote_hangup' && call.direction === 'in') { dismissCallUiLocally(call); } const shouldNotifyRemoteFailure = !suppressRemoteSignal && !notifyRemoteHangup && Boolean(call.remoteSessionId) && String(localReasonCode || '') !== 'completed' && String(debugReason || '') !== 'remote_hangup'; if ((notifyRemoteHangup || shouldNotifyRemoteFailure) && call.remoteSessionId) { try { const dataValue = notifyRemoteHangup ? '' : `setup_failed:${String(localReasonCode || 'error')}:${String(debugReason || '').slice(0, 80)}`; await sendSignal(call, TYPES.HANGUP, dataValue); } catch {} } await closeMedia(call); if (String(localReasonCode || '') === 'completed') { await emitDebug(call, 'info', 'debug_connection_success', debugReason || 'completed'); } if (debugReason) { await emitDebug(call, 'info', 'call_finalize', `${localReasonCode}:${debugReason}`); } const reasonText = debugReason || localReasonCode; if (!suppressReports && String(localReasonCode || '') !== 'completed') { const failureStage = call.phase || ''; const failureContext = { failureStage, connectedBeforeFailure: Boolean(call.connectedAtMs), ...diagnosticsBeforeClose, }; if (call.direction === 'out') { await sendCallDeliveryReport(call, 'outgoing_failed', `outgoing_${localReasonCode}`, reasonText, failureContext); } else if (call.direction === 'in') { await sendCallDeliveryReport(call, 'incoming_failed', `incoming_${localReasonCode}`, reasonText, failureContext); } if (String(localReasonCode || '') === 'busy') { await sendCallDeliveryReport(call, 'call_busy', 'call_busy', reasonText, failureContext); } else if (String(localReasonCode || '') === 'declined') { await sendCallDeliveryReport(call, 'call_declined', 'call_declined', reasonText, failureContext); } if (String(localReasonCode || '') === 'error') { await sendCallDeliveryReport(call, 'unknown_error', 'call_unknown_error', reasonText, failureContext); } } if (!suppressSummary) { await sendOutgoingCallSummaryDm(call, localReasonCode); } call.phase = 'ended'; call.statusText = 'Звонок завершён'; if (String(localReasonCode || '') === 'busy') { call.statusText = 'Пользователь занят'; } recordCallTimeline(call, 'finalize_end', `reason=${localReasonCode || ''}; status=${call.statusText || ''}`); notifyCallState(); const finalHoldMs = String(localReasonCode || '') === 'busy' ? 2600 : 0; if (finalHoldMs > 0) { window.setTimeout(() => { calls.delete(call.callId); if (activeCallId === call.callId) { activeCallId = ''; } notifyCallState(); }, finalHoldMs); return; } calls.delete(call.callId); if (activeCallId === call.callId) { activeCallId = ''; } notifyCallState(); } async function emitDebug(call, level, message, details = '') { if (!call?.debugRunId || typeof debugReporter !== 'function') return; try { await debugReporter({ runId: call.debugRunId, level, message, details, }); } catch {} } async function sendSignal(call, type, data = '') { if (!call.remoteSessionId) return; const signalName = getSignalTypeName(type); const signalType = getCallSignalType(type); const login = String(state?.session?.login || '').trim(); const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); if (!login || !storagePwd || !signalType) { throw new Error('Call SendSignal: missing login/storagePwd/signalType'); } try { await authService.sendSignal({ toLogin: call.peerLogin, targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: call.remoteSessionId, signalType, signalRequestId: call.callId, data: encodeCallSignalData(call.callId, type, data), storagePwd, includeClientSignature: true, }); recordCallTimeline(call, `signal_out_${signalName}`, `toSession=${call.remoteSessionId}; len=${String(data || '').length}`); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); } catch (error) { if (String(error?.code || '').toUpperCase() === 'SESSION_NOT_FOUND') { recordCallTimeline( call, `signal_out_${signalName}_session_not_found`, `toSession=${call.remoteSessionId}; op=${String(error?.op || '')}; status=${String(error?.status || '')}`, 'warn', ); } recordCallTimeline(call, `signal_out_${signalName}_failed`, `toSession=${call.remoteSessionId}; error=${toErrorText(error)}`, 'warn'); await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error)); throw error; } } async function broadcastSignal(call, type, data = '') { const signalName = getSignalTypeName(type); const signalType = getCallSignalType(type); const login = String(state?.session?.login || '').trim(); const storagePwd = String(state?.session?.storagePwdInMemory || '').trim(); if (!call?.peerLogin || !login || !storagePwd || !signalType) { throw new Error('Call SendSignal broadcast: missing peer/login/storagePwd/signalType'); } try { const response = await authService.sendSignal({ toLogin: call.peerLogin, targetMode: SIGNAL_TARGET_ALL, signalType, signalRequestId: call.callId, data: encodeCallSignalData(call.callId, type, data), storagePwd, includeClientSignature: true, }); recordCallTimeline( call, `signal_broadcast_${signalName}`, `ws=${Number(response?.deliveredWsSessions || response?.deliveredCount || 0)}; push=${Number(response?.deliveredWebPushSessions || response?.deliveredFcmSessions || 0)}`, ); return response || {}; } catch (error) { recordCallTimeline(call, `signal_broadcast_${signalName}_failed`, toErrorText(error), 'warn'); throw error; } } async function notifyPeerSessionsAboutConnectStart(call) { const winnerSessionId = String(call?.remoteSessionId || '').trim(); if (!call || !winnerSessionId) return; const data = buildConnectStartData(winnerSessionId); try { await broadcastSignal(call, TYPES.CONNECT_START, data); recordCallTimeline(call, 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); await emitDebug(call, 'info', 'connect_start_broadcast_sent', `winnerSession=${winnerSessionId}`); } catch (error) { recordCallTimeline(call, 'connect_start_broadcast_failed', `winnerSession=${winnerSessionId}; error=${toErrorText(error)}`, 'warn'); await emitDebug(call, 'warn', 'connect_start_broadcast_failed', toErrorText(error)); } } 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; const iceServers = await resolveIceServers(call); const pc = new RTCPeerConnection({ iceServers, }); if (call.debugMode && call.debugRole === 'initiator') { const dc = pc.createDataChannel('debug-ping'); dc.onopen = () => { try { dc.send('ping'); } catch {} void emitDebug(call, 'info', 'debug_datachannel_open', 'sent ping'); }; dc.onmessage = (evt) => { void emitDebug(call, 'info', 'debug_datachannel_message', String(evt?.data || '')); }; } pc.ondatachannel = (evt) => { const ch = evt?.channel; if (!ch) return; ch.onmessage = (msg) => { const incoming = String(msg?.data || ''); void emitDebug(call, 'info', 'debug_datachannel_message_in', incoming); if (incoming === 'ping') { try { ch.send('pong'); } catch {} } }; }; pc.onicecandidate = async (event) => { if (!event.candidate || !call.remoteSessionId) return; call.localIceSentCount = Number(call.localIceSentCount || 0) + 1; if (call.localIceSentCount <= 3) { recordCallTimeline( call, 'ice_local_candidate', `count=${call.localIceSentCount}; type=${event.candidate?.type || ''}; protocol=${event.candidate?.protocol || ''}`, ); } try { await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate)); } catch {} }; pc.onconnectionstatechange = () => { const state = pc.connectionState; recordCallTimeline( call, 'pc_connection_state', `state=${state || ''}; ice=${pc.iceConnectionState || ''}; signal=${pc.signalingState || ''}`, state === 'failed' ? 'warn' : 'info', ); if (state === 'connected') { stopReconnectFlow(call); if (call.timers?.incomingConnect20s) { clearTimeout(call.timers.incomingConnect20s); call.timers.incomingConnect20s = null; } if (!call.connectedAtMs) { call.connectedAtMs = nowMs(); } refreshVideoSlotReady(call); setActiveStatus(call); startTransportProbe(call); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); if (call.direction === 'out' && !call.connectionSuccessReported) { call.connectionSuccessReported = true; void (async () => { const route = await detectConnectionRoute(call); const candidateAnalytics = await collectIceCandidateAnalytics(call); if (route?.label) { call.connectionRouteLabel = route.label; } call.connectionRouteDetails = route?.details || ''; await sendCallDeliveryReport( call, 'call_connected', 'call_connected_success', `connected:${route?.label || 'unknown_route'}`, { reportBy: 'initiator', routeLabel: route?.label || '', routeDetails: route?.details || '', localIp: route?.localIp || '', remoteIp: route?.remoteIp || '', turnCandidateAddress: route?.turnCandidateAddress || '', ...candidateAnalytics, }, ); })(); } return; } if (state === 'failed') { const failedDetails = `failed;ice=${pc.iceConnectionState || ''};gather=${pc.iceGatheringState || ''};signal=${pc.signalingState || ''}`; if (call.connectedAtMs) { startReconnectFlow(call, 'failed'); return; } void emitDebug(call, 'warn', 'peer_connection_closed', failedDetails); void finalizeCall(call, { localReasonCode: 'error', debugReason: failedDetails }); return; } if (state === 'disconnected' && call.phase !== 'ended') { if (call.connectedAtMs) { startReconnectFlow(call, 'disconnected'); return; } void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`); return; } if (state === 'closed' && call.phase !== 'ended') { void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`); if (call.connectedAtMs) { void finalizeCall(call, { localReasonCode: 'error', debugReason: state }); return; } void finalizeCall(call, { localReasonCode: 'error', debugReason: state }); } }; 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}`); stream.getTracks().forEach((track) => { track.enabled = track.kind === 'audio' ? !call.muted : true; const sender = pc.addTrack(track, stream); if (track.kind === 'audio') { call.audioSenders.push(sender); } }); 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'); await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e)); throw e; } pc.ontrack = (evt) => { const trackKind = String(evt?.track?.kind || ''); recordCallTimeline(call, 'remote_track_received', `kind=${trackKind || '-'}; streams=${evt?.streams?.length || 0}`); let remoteStream = call.remoteMediaStream || null; if (evt?.streams?.[0]) { remoteStream = evt.streams[0]; } 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 (trackKind === 'video') { call.videoEverActivated = true; } if (!call.remoteAudio) { const audio = new Audio(); audio.autoplay = true; audio.playsInline = true; call.remoteAudio = audio; } if (evt?.track) ensureRemoteTrackObservers(call, evt.track); call.remoteAudio.srcObject = remoteStream; syncRemoteReceiverVideoTracks(call); void call.remoteAudio.play?.().catch?.(() => {}); notifyCallState(); }; call.pc = pc; if (!Array.isArray(call.pendingRemoteIceCandidates)) { call.pendingRemoteIceCandidates = []; } 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_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 { 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=${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}`); }); } } } async function onAccept(call) { if (!call) return; if (call.initialOfferInProgress || call.initialOfferSent) { await emitDebug(call, 'warn', 'accept_duplicate_ignored', `phase=${call.phase || ''}`); return; } recordCallTimeline(call, 'offer_start', `remoteSession=${call.remoteSessionId || ''}`); call.initialOfferInProgress = true; cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); try { await notifyPeerSessionsAboutConnectStart(call); const pc = await ensurePeerConnection(call); ensureReservedVideoTransceiver(call); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); call.initialOfferSent = true; await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); await emitDebug(call, 'info', 'offer_sent', 'offer created and sent'); } finally { call.initialOfferInProgress = false; } } function ensureIncomingNotification(peerLogin) { if (typeof window === 'undefined') return; const text = `Вам звонит ${peerLogin}`; try { if ('Notification' in window && Notification.permission === 'granted') { new Notification('SHiNE: входящий звонок', { body: text }); } } catch {} try { if ('vibrate' in navigator) { navigator.vibrate([180, 70, 180, 70, 260]); } } catch {} } function isIncomingCallPushFresh(payload) { const expiresAtMs = Number(payload?.expiresAtMs || 0); if (Number.isFinite(expiresAtMs) && expiresAtMs > 0 && Date.now() > expiresAtMs) { return false; } return true; } function isCallPushForCurrentSession(payload = {}) { const targetSessionId = String(payload?.targetSessionId || '').trim(); if (!targetSessionId) return true; const currentSessionId = String(state?.session?.sessionId || '').trim(); return Boolean(currentSessionId) && currentSessionId === targetSessionId; } async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { const callId = String(payload?.callId || '').trim(); const fromLogin = String(payload?.fromLogin || '').trim(); const fromSessionId = String(payload?.fromSessionId || '').trim(); const type = Number(payload?.type || TYPES.INVITE); if (type === TYPES.CONNECT_START) { const call = getCall(callId); if (!call || call.direction !== 'in' || call.phase === 'ended') return call; const winnerSessionId = parseConnectStartWinnerSessionId(payload?.data || ''); const currentSessionId = String(state?.session?.sessionId || '').trim(); recordCallTimeline(call, 'connect_start_broadcast_received', `winnerSession=${winnerSessionId || '-'}; currentSession=${currentSessionId || '-'}`); if (winnerSessionId && currentSessionId && winnerSessionId === currentSessionId) { await emitDebug(call, 'info', 'connect_start_kept_on_winner_session', winnerSessionId); return call; } dismissCallUiLocally(call); await finalizeCall(call, { localReasonCode: 'completed', debugReason: 'answered_elsewhere_session_selected', suppressRemoteSignal: true, suppressReports: true, suppressSummary: true, }); return call; } if (!callId || !fromLogin || !fromSessionId) return null; if (activeCallId && activeCallId !== callId) { try { await authService.sendSignal({ toLogin: fromLogin, targetMode: SIGNAL_TARGET_SINGLE, targetSessionId: fromSessionId, signalType: getCallSignalType(TYPES.DECLINE_BUSY), signalRequestId: callId, data: encodeCallSignalData(callId, TYPES.DECLINE_BUSY, 'busy'), storagePwd: state?.session?.storagePwdInMemory || '', includeClientSignature: true, }); } catch {} return null; } let call = getCall(callId); if (!call) { call = { callId, peerLogin: fromLogin, direction: 'in', phase: 'incoming', statusText: `Вам звонит ${fromLogin}`, remoteSessionId: fromSessionId, timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, localVideoTrack: null, remoteMediaStream: null, audioSenders: [], videoSender: null, videoTransceiver: null, muted: false, cameraEnabled: false, videoEverActivated: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', pendingRemoteIceCandidates: [], pendingNegotiationReason: '', initialOfferInProgress: false, initialOfferSent: false, timelineEvents: [], timelineSeq: 0, timelineBaseMs: nowMs(), }; calls.set(callId, call); recordCallTimeline(call, 'incoming_invite_created', `from=${fromLogin}; session=${fromSessionId}; source=${source}`); } else if (!call.remoteSessionId && fromSessionId) { setRemoteSessionId(call, fromSessionId, 'incoming_invite_existing_call'); } activeCallId = callId; setStatus(call, `Вам звонит ${fromLogin}`, 'incoming'); ensureIncomingNotification(fromLogin); try { await sendSignal(call, TYPES.RINGING, `ringing:${source}`); } catch {} if (!call.timers.incoming20s) { call.timers.incoming20s = setTimeout(async () => { if (!calls.has(callId)) return; try { await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s'); } catch {} await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' }); }, 20000); } return call; } export function setCallDebugReporter(fn) { debugReporter = typeof fn === 'function' ? fn : null; } export function subscribeCallState(listener) { if (typeof listener !== 'function') { return () => {}; } callStateListeners.add(listener); try { listener(getCallStateSnapshot()); } catch {} return () => { callStateListeners.delete(listener); }; } export function getActiveCallState() { return getCallStateSnapshot(); } async function applyMicState(call) { if (!call) return; const muted = Boolean(call.muted); const audioTracks = call.localStream?.getAudioTracks?.() || []; audioTracks.forEach((track) => { track.enabled = !muted; }); const senders = Array.isArray(call.audioSenders) && call.audioSenders.length > 0 ? call.audioSenders : (call.pc?.getSenders?.() || []); const sourceTrack = audioTracks[0] || null; for (const sender of senders) { if (!sender || typeof sender.replaceTrack !== 'function') continue; try { if (muted) { if (sender.track) { await sender.replaceTrack(null); } } else if (sourceTrack) { if (sender.track !== sourceTrack) { await sender.replaceTrack(sourceTrack); } sourceTrack.enabled = true; } } catch {} } } 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; 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) { try { videoTrack.enabled = false; } catch {} try { call.localStream?.removeTrack?.(videoTrack); } catch {} try { videoTrack.stop(); } catch {} } call.localVideoTrack = null; } 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; 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) { 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 { const blackTrack = ensureBlackVideoTrack(call); 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'); } await disableCameraTrack(call); } notifyCallState(); return true; } export async function setMicMuted(muted) { const call = getActiveCall(); if (!call) return; call.muted = Boolean(muted); await applyMicState(call); notifyCallState(); } export async function toggleMicMuted() { const call = getActiveCall(); if (!call) return; 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); 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() { const call = getActiveCall(); if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false; const audio = call.remoteAudio; if (Boolean(call.speakerEnabled)) { try { await audio.setSinkId(''); call.speakerEnabled = false; notifyCallState(); return true; } catch { return false; } } try { const options = call.speakerSinkId ? { deviceId: call.speakerSinkId } : undefined; const selected = await navigator.mediaDevices.selectAudioOutput(options); const deviceId = String(selected?.deviceId || '').trim(); if (!deviceId) return false; await audio.setSinkId(deviceId); call.speakerSinkId = deviceId; call.speakerEnabled = true; notifyCallState(); return true; } catch { return false; } } export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) { const cleanCallId = String(callId || '').trim(); const cleanPeerLogin = String(peerLogin || '').trim(); const cleanPeerSessionId = String(peerSessionId || '').trim(); if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return; let call = getCall(cleanCallId); if (!call) { call = { callId: cleanCallId, peerLogin: cleanPeerLogin, direction: 'in', phase: 'incoming', statusText: 'Debug: responder ждёт offer', remoteSessionId: cleanPeerSessionId, timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, localVideoTrack: null, 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, }; calls.set(cleanCallId, call); } if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; if (typeof call.initialOfferInProgress !== 'boolean') call.initialOfferInProgress = false; if (typeof call.initialOfferSent !== 'boolean') call.initialOfferSent = false; activeCallId = cleanCallId; await emitDebug(call, 'info', 'debug_prepare_responder', `peerSessionId=${cleanPeerSessionId}`); setStatus(call, 'Debug: responder готов, ждём offer', 'incoming'); } export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin, peerSessionId }) { const cleanCallId = String(callId || '').trim(); const cleanPeerLogin = String(peerLogin || '').trim(); const cleanPeerSessionId = String(peerSessionId || '').trim(); if (!cleanCallId || !cleanPeerLogin || !cleanPeerSessionId) return; const call = { callId: cleanCallId, peerLogin: cleanPeerLogin, direction: 'out', phase: 'connecting', statusText: 'Debug: старт соединения', remoteSessionId: cleanPeerSessionId, timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, localVideoTrack: null, 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: [], timelineSeq: 0, timelineBaseMs: nowMs(), }; calls.set(cleanCallId, call); activeCallId = cleanCallId; recordCallTimeline(call, 'debug_initiator_created', `peer=${cleanPeerLogin}; session=${cleanPeerSessionId}`); notifyCallState(); await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`); try { await onAccept(call); } catch (error) { await emitDebug(call, 'error', 'debug_initiator_start_failed', toErrorText(error)); await finalizeCall(call, { localReasonCode: 'error', debugReason: toErrorText(error) }); } } export async function startOutgoingCall(peerLogin) { const cleanPeer = String(peerLogin || '').trim(); if (!cleanPeer) return; const active = getActiveCall(); if (active) { throw new Error(`Уже есть активный звонок с ${active.peerLogin || 'другим пользователем'}`); } const callId = makeCallId(); const preflightTimeoutMs = resolveCallPreflightTimeoutMs(); const preflightOk = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: false }); if (!preflightOk) { throw new Error('Сервер временно недоступен'); } const call = { callId, peerLogin: cleanPeer, direction: 'out', phase: 'searching', statusText: 'Ищем пользователя…', remoteSessionId: '', timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, localVideoTrack: null, remoteMediaStream: null, audioSenders: [], videoSender: null, videoTransceiver: null, muted: false, cameraEnabled: false, videoEverActivated: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', pendingRemoteIceCandidates: [], pendingNegotiationReason: '', initialOfferInProgress: false, initialOfferSent: false, timelineEvents: [], timelineSeq: 0, timelineBaseMs: nowMs(), }; calls.set(callId, call); activeCallId = callId; recordCallTimeline(call, 'outgoing_created', `peer=${cleanPeer}`); setStatus(call, 'Ищем пользователя…', 'searching'); call.timers.total35s = setTimeout(() => { if (!calls.has(callId)) return; if (!call.connectedAtMs) { recordCallTimeline(call, 'total_timeout_fired', 'total_timeout_35s', 'warn'); void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' }); } }, 35000); try { call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); recordCallTimeline( call, 'invite_broadcast_ok', `ws=${Number(call.inviteDelivery?.deliveredWsSessions || 0)}; push=${Number(call.inviteDelivery?.deliveredWebPushSessions || call.inviteDelivery?.deliveredFcmSessions || 0)}`, ); if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } scheduleOutgoingAckTimeout(call); } catch (error) { recordCallTimeline(call, 'invite_broadcast_failed', toErrorText(error), 'warn'); const text = String(error?.message || '').toUpperCase(); const isNotAuth = text.includes('NOT_AUTHENTICATED'); if (isNotAuth) { const recovered = await ensureSessionForCall({ timeoutMs: preflightTimeoutMs, force: true }); if (recovered) { try { call.inviteDelivery = await broadcastSignal(call, TYPES.INVITE); if (getInviteDeliveredCount(call) > 0 && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } scheduleOutgoingAckTimeout(call); return; } catch {} } await finalizeCall(call, { localReasonCode: 'error', debugReason: 'invite_failed:not_authenticated_after_retry' }); throw new Error('Сервер временно недоступен'); } await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` }); throw error; } } export async function handleIncomingCallInvite(evt) { await handleIncomingInvitePayload(evt?.payload || {}, { source: 'ws' }); } export async function handleIncomingCallSignalViaSendSignal(evt) { const payload = evt?.payload || {}; const signalType = String(payload?.signalType || '').trim(); if (!signalType.startsWith('call_')) return false; const decoded = decodeCallSignalData(payload?.data || ''); if (!Number.isFinite(decoded.type)) return true; const normalizedEvent = { payload: { callId: decoded.callId || String(payload?.signalRequestId || '').trim(), fromLogin: payload?.fromLogin, fromSessionId: payload?.fromSessionId, type: decoded.type, data: decoded.data, }, }; if (decoded.type === TYPES.INVITE || decoded.type === TYPES.CONNECT_START) { await handleIncomingInvitePayload(normalizedEvent.payload, { source: 'send_signal' }); return true; } await handleIncomingCallSignal(normalizedEvent); return true; } export async function acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; recordCallTimeline(call, 'incoming_accept_clicked', `session=${call.remoteSessionId || ''}`); call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); cleanupTimers(call); scheduleIncomingConnectTimeout(call); await sendSignal(call, TYPES.ACCEPT, 'accept'); } export async function declineIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; dismissCallUiLocally(call); const declinePromise = (async () => { try { await sendSignal(call, TYPES.DECLINE_BUSY, 'decline'); } catch {} })(); await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user', suppressRemoteSignal: true, }); await declinePromise; } export async function handleIncomingCallSignal(evt) { const payload = evt?.payload || {}; const callId = String(payload.callId || '').trim(); const fromLogin = String(payload.fromLogin || '').trim(); const fromSessionId = String(payload.fromSessionId || '').trim(); const type = Number(payload.type); const data = String(payload.data || ''); if (!callId || !fromLogin || !Number.isFinite(type)) return; const call = getCall(callId); if (!call) return; const signalName = getSignalTypeName(type); recordCallTimeline(call, `signal_in_${signalName}`, `from=${fromSessionId || '-'}; len=${data.length}`); if (call.direction === 'out') { if (type === TYPES.RINGING) { recordCallTimeline(call, 'ringing_received', `from=${fromSessionId || '-'}`); } else if (type === TYPES.ACCEPT) { if (fromSessionId) { if (!call.remoteSessionId) { setRemoteSessionId(call, fromSessionId, 'accept'); } else if (call.remoteSessionId !== fromSessionId) { recordCallTimeline(call, 'signal_in_accept_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, 'warn'); await emitDebug( call, 'warn', 'accept_from_non_selected_session_ignored', `selected=${call.remoteSessionId}; from=${fromSessionId}`, ); return; } } } else { const remoteSessionLocked = Boolean( call.initialOfferSent || call.connectedAtMs || call.phase === 'connecting' || call.phase === 'active' || call.phase === 'reconnecting', ); const terminalSignalFromAnotherSession = Boolean(call.remoteSessionId) && Boolean(fromSessionId) && call.remoteSessionId !== fromSessionId && (type === TYPES.DECLINE_BUSY || type === TYPES.TIMEOUT || type === TYPES.HANGUP); if (call.remoteSessionId && fromSessionId && call.remoteSessionId !== fromSessionId) { if (terminalSignalFromAnotherSession && !remoteSessionLocked) { recordCallTimeline(call, 'terminal_signal_before_lock_allowed', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`); await emitDebug( call, 'info', 'terminal_signal_from_non_selected_session_allowed_before_lock', `type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`, ); } else { recordCallTimeline(call, 'signal_in_ignored_non_selected_session', `type=${signalName}; selected=${call.remoteSessionId}; from=${fromSessionId}`); await emitDebug( call, 'info', 'signal_from_non_selected_session_ignored', `type=${type}; selected=${call.remoteSessionId}; from=${fromSessionId}`, ); return; } } if (!call.remoteSessionId && fromSessionId) { setRemoteSessionId(call, fromSessionId, `first_${signalName}`); } } } else if (!call.remoteSessionId) { setRemoteSessionId(call, fromSessionId, `incoming_${signalName}`); } if (type === TYPES.RINGING) { if (call.direction === 'out' && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } return; } if (type === TYPES.CONNECT_START) { const winnerSessionId = parseConnectStartWinnerSessionId(data); recordCallTimeline(call, 'connect_start_received', `winnerSession=${winnerSessionId || '-'}; currentRemoteSession=${call.remoteSessionId || '-'}`); return; } if (type === TYPES.ACCEPT) { if (call.direction !== 'out') { await emitDebug(call, 'warn', 'accept_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`); return; } call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); await onAccept(call); return; } if (type === TYPES.DECLINE_BUSY) { const normalized = data.trim().toLowerCase(); const isDeclined = normalized === 'decline' || normalized === 'declined'; await finalizeCall(call, { localReasonCode: isDeclined ? 'declined' : 'busy', debugReason: isDeclined ? 'declined_by_remote' : 'busy_by_remote', }); return; } if (type === TYPES.TIMEOUT) { await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'remote_timeout' }); return; } if (type === TYPES.HANGUP) { await finalizeCall(call, { localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer', debugReason: 'remote_hangup', }); return; } if (type === TYPES.OFFER) { try { recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`); const pc = await ensurePeerConnection(call); 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); ensureReservedVideoTransceiver(call); 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'); } catch (error) { await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error)); await finalizeCall(call, { localReasonCode: 'error', debugReason: `offer_failed:${toErrorText(error)}` }); } return; } if (type === TYPES.ANSWER) { try { if (call.direction !== 'out') { await emitDebug(call, 'warn', 'answer_ignored_for_non_outgoing_call', `direction=${call.direction || ''}`); return; } if (!call.pc) { await emitDebug(call, 'warn', 'answer_ignored_without_pc', 'no local peer connection'); return; } const pc = call.pc; const localType = String(pc.localDescription?.type || '').trim().toLowerCase(); if (localType !== 'offer') { await emitDebug(call, 'warn', 'answer_ignored_without_local_offer', `localType=${localType || 'none'}`); return; } if (pc.signalingState === 'stable' && pc.remoteDescription) { await emitDebug(call, 'warn', 'answer_duplicate_ignored', 'remote description already set'); return; } await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); ensureReservedVideoTransceiver(call); syncRemoteReceiverVideoTracks(call); await flushPendingIceCandidates(call); refreshVideoSlotReady(call); recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`); setStatus(call, 'Соединяем…', 'connecting'); notifyCallState(); await emitDebug(call, 'info', 'answer_processed', 'remote description set'); } catch (error) { await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error)); await finalizeCall(call, { localReasonCode: 'error', debugReason: `answer_failed:${toErrorText(error)}` }); } return; } if (type === TYPES.ICE) { try { const candidate = JSON.parse(data); call.remoteIceReceivedCount = Number(call.remoteIceReceivedCount || 0) + 1; if (!call.pc) { if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; call.pendingRemoteIceCandidates.push(candidate); if (call.remoteIceReceivedCount <= 3) { recordCallTimeline(call, 'ice_remote_queued_no_pc', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`); } await emitDebug(call, 'info', 'ice_queued_before_pc', `queue=${call.pendingRemoteIceCandidates.length}`); return; } const pc = call.pc; if (!pc.remoteDescription) { if (!Array.isArray(call.pendingRemoteIceCandidates)) call.pendingRemoteIceCandidates = []; call.pendingRemoteIceCandidates.push(candidate); if (call.remoteIceReceivedCount <= 3) { recordCallTimeline(call, 'ice_remote_queued_no_remote_description', `count=${call.remoteIceReceivedCount}; queue=${call.pendingRemoteIceCandidates.length}`); } await emitDebug(call, 'info', 'ice_queued_before_remote_description', `queue=${call.pendingRemoteIceCandidates.length}`); return; } await pc.addIceCandidate(new RTCIceCandidate(candidate)); if (call.remoteIceReceivedCount <= 3) { recordCallTimeline(call, 'ice_remote_applied', `count=${call.remoteIceReceivedCount}`); } await emitDebug(call, 'info', 'ice_processed', 'candidate added'); } catch (error) { await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error)); } } } export async function hangupActiveCall() { if (!activeCallId) return; const call = getCall(activeCallId); dismissCallUiLocally(call); recordCallTimeline(call, 'hangup_clicked', `connected=${Boolean(call?.connectedAtMs)}`); await finalizeCall(call, { localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer', debugReason: 'hangup_by_user', notifyRemoteHangup: true, }); } export async function handleIncomingCallPush(payload = {}) { if (!isCallPushForCurrentSession(payload)) return; if (!isIncomingCallPushFresh(payload)) return; await handleIncomingInvitePayload(payload, { source: 'push' }); } export async function handleStopCallPush(payload = {}) { if (!isCallPushForCurrentSession(payload)) return; const callId = String(payload?.callId || '').trim(); if (!callId) return; const call = getCall(callId); if (!call) return; const fromSessionId = String(payload?.fromSessionId || '').trim(); const reason = String(payload?.reason || 'stop_call_push').trim() || 'stop_call_push'; const currentSessionId = String(state?.session?.sessionId || '').trim(); if (fromSessionId && currentSessionId && fromSessionId === currentSessionId) { await emitDebug(call, 'info', 'stop_call_push_ignored_for_origin_session', reason); return; } recordCallTimeline(call, 'stop_call_push', `fromSession=${fromSessionId || '-'}; reason=${reason}`); await finalizeCall(call, { localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer', debugReason: `stop_call_push:${reason}`, suppressRemoteSignal: true, suppressReports: true, suppressSummary: true, }); } export async function handleCallPushAction(action, payload = {}) { if (!isCallPushForCurrentSession(payload)) return; const normalized = String(action || '').trim().toLowerCase(); if (normalized !== 'accept' && normalized !== 'decline') return; if (!isIncomingCallPushFresh(payload)) return; const timeoutMs = resolveCallPreflightTimeoutMs(); const ok = await ensureSessionForCall({ timeoutMs, force: false }); if (!ok) { throw new Error('Не удалось подключиться, вызов завершён'); } await handleIncomingCallPush(payload); if (normalized === 'accept') { await acceptIncomingCall(); return; } await declineIncomingCall(); }