import { addSystemChatMessage, authService } from '../state.js'; const TYPES = { INVITE: 100, RINGING: 110, ACCEPT: 120, DECLINE_BUSY: 130, TIMEOUT: 140, HANGUP: 150, OFFER: 200, ANSWER: 210, ICE: 220, }; 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' }, ]); function nowMs() { return Date.now(); } 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 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; } 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 iceServers = []; if (stunUrls.length > 0) { iceServers.push({ urls: stunUrls.length === 1 ? stunUrls[0] : stunUrls }); } if (turnUrls.length > 0 && turnUsername && turnPassword) { 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(); } await emitDebug(call, 'info', 'call_ice_loaded_from_server', `stun=${stunUrls.length}; turn=${turnUrls.length}`); return iceServers; } catch (error) { await emitDebug(call, 'warn', 'call_ice_load_failed', toErrorText(error)); return cloneDefaultIceServers(); } } 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; const callPhase = String(call.phase || '').trim(); return { callId: call.callId, peerLogin: call.peerLogin || '', direction: call.direction || 'out', phase: callPhase, statusText: call.statusText || '', muted: Boolean(call.muted), canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', canHangup: callPhase !== 'ended' && callPhase !== 'incoming', canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting', }; } function notifyCallState() { const snapshot = getCallStateSnapshot(); callStateListeners.forEach((listener) => { try { listener(snapshot); } catch {} }); } 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(); void emitDebug(call, 'info', `call_status: ${call.statusText}`, `callId=${call.callId}`); notifyCallState(); } function buildActiveStatusText(call) { const route = String(call?.connectionRouteLabel || '').trim(); return route ? `Разговор идёт (${route})` : 'Разговор идёт'; } function setActiveStatus(call) { setStatus(call, buildActiveStatusText(call), 'active'); } 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?.transportProbe) clearInterval(call.timers.transportProbe); call.timers.transportProbe = null; } async function closeMedia(call) { try { call.pc?.close(); } catch {} try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {} call.pc = null; call.localStream = null; call.audioSenders = []; call.connectionRouteLabel = ''; } 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: '' }; 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: '' }; 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 details = `local=${localType || '-'}; remote=${remoteType || '-'}`; if (localType === 'relay' || remoteType === 'relay') { return { label: 'через TURN', details }; } if (localType || remoteType) { return { label: 'прямое', details }; } return { label: '', details }; } catch { return { label: '', details: '' }; } } 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; 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; 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; 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(); } function pushCallSummary(call, summaryCode) { if (!call?.peerLogin) return; const outgoing = call.direction === 'out'; const from = outgoing ? 'out' : 'in'; if (summaryCode === 'busy') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (занято)' : `[Звонок] Пропущенный входящий от ${call.peerLogin} (занято)`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'no_answer') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (нет ответа)' : `[Звонок] Пропущенный входящий от ${call.peerLogin}`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'declined') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (отклонён)' : `[Звонок] Входящий звонок от ${call.peerLogin} отклонён`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'error') { const text = outgoing ? '[Звонок] Исходящий: не дозвонились (ошибка соединения)' : `[Звонок] Входящий звонок от ${call.peerLogin}: ошибка соединения`; addSystemChatMessage(call.peerLogin, text, { from, kind: 'call-tech' }); return; } if (summaryCode === 'completed') { const duration = formatDuration(nowMs() - Number(call.connectedAtMs || call.startedAtMs || nowMs())); const label = outgoing ? 'Исходящий' : 'Входящий'; addSystemChatMessage(call.peerLogin, `[Звонок] ${label}: разговор ${duration}`, { from, kind: 'call-tech', }); } } async function finalizeCall(call, { localReasonCode = 'error', debugReason = '', notifyRemoteHangup = false, } = {}) { if (!call) return; cleanupTimers(call); stopReconnectFlow(call); stopTone(); if (notifyRemoteHangup && call.remoteSessionId) { try { await authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type: TYPES.HANGUP, data: '', }); } 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}`); } pushCallSummary(call, localReasonCode); call.phase = 'ended'; call.statusText = 'Звонок завершён'; notifyCallState(); 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; try { await authService.callSignalToSession({ toLogin: call.peerLogin, targetSessionId: call.remoteSessionId, callId: call.callId, type, data, }); await emitDebug(call, 'info', `signal_sent_${type}`, `len=${String(data || '').length}`); } catch (error) { await emitDebug(call, 'error', `signal_send_failed_${type}`, toErrorText(error)); throw error; } } 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; try { await sendSignal(call, TYPES.ICE, JSON.stringify(event.candidate)); } catch {} }; pc.onconnectionstatechange = () => { const state = pc.connectionState; if (state === 'connected') { stopReconnectFlow(call); if (!call.connectedAtMs) { call.connectedAtMs = nowMs(); } setActiveStatus(call); startTransportProbe(call); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); return; } if (state === 'failed') { if (call.connectedAtMs) { startReconnectFlow(call, 'failed'); return; } void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`); void finalizeCall(call, { localReasonCode: 'error', debugReason: 'failed' }); 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 }); } }; try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); call.localStream = stream; call.audioSenders = []; call.connectionRouteLabel = ''; 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); } }); } catch (e) { setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed'); await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e)); throw e; } pc.ontrack = (evt) => { const audio = new Audio(); audio.autoplay = true; audio.srcObject = evt.streams[0]; call.remoteAudio = audio; }; call.pc = pc; return pc; } async function onAccept(call) { cleanupTimers(call); setStatus(call, 'Соединяем…', 'connecting'); const pc = await ensurePeerConnection(call); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); await emitDebug(call, 'info', 'offer_sent', 'offer created and sent'); } 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 {} } 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 {} } } 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 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, audioSenders: [], muted: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'responder', }; calls.set(cleanCallId, call); } 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, audioSenders: [], muted: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), debugRole: 'initiator', }; calls.set(cleanCallId, call); activeCallId = cleanCallId; 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 call = { callId, peerLogin: cleanPeer, direction: 'out', phase: 'searching', statusText: 'Ищем пользователя…', remoteSessionId: '', timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, pc: null, localStream: null, audioSenders: [], muted: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', }; calls.set(callId, call); activeCallId = callId; setStatus(call, 'Ищем пользователя…', 'searching'); call.timers.ack10s = setTimeout(() => { if (!calls.has(callId)) return; if (call.phase === 'searching') { void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' }); } }, 10000); call.timers.total35s = setTimeout(() => { if (!calls.has(callId)) return; if (!call.connectedAtMs) { void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' }); } }, 35000); try { await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); } catch (error) { await finalizeCall(call, { localReasonCode: 'error', debugReason: `invite_failed:${toErrorText(error)}` }); throw error; } } export async function handleIncomingCallInvite(evt) { const payload = evt?.payload || {}; const callId = String(payload.callId || '').trim(); const fromLogin = String(payload.fromLogin || '').trim(); const fromSessionId = String(payload.fromSessionId || '').trim(); if (!callId || !fromLogin || !fromSessionId) return; if (activeCallId && activeCallId !== callId) { try { await authService.callSignalToSession({ toLogin: fromLogin, targetSessionId: fromSessionId, callId, type: TYPES.DECLINE_BUSY, data: 'busy', }); } catch {} return; } 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, audioSenders: [], muted: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, debugRunId: '', debugRole: '', }; calls.set(callId, call); } activeCallId = callId; setStatus(call, `Вам звонит ${fromLogin}`, 'incoming'); ensureIncomingNotification(fromLogin); try { await sendSignal(call, TYPES.RINGING, 'ringing'); } catch {} 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); } export async function acceptIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); cleanupTimers(call); await sendSignal(call, TYPES.ACCEPT, 'accept'); } export async function declineIncomingCall() { const call = getActiveCall(); if (!call || call.direction !== 'in' || call.phase !== 'incoming') return; try { await sendSignal(call, TYPES.DECLINE_BUSY, 'decline'); } catch {} await finalizeCall(call, { localReasonCode: 'declined', debugReason: 'declined_by_user' }); } 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; if (!call.remoteSessionId) call.remoteSessionId = fromSessionId; if (type === TYPES.RINGING) { if (call.direction === 'out' && call.phase === 'searching') { setStatus(call, 'Вызываем…', 'ringing'); } return; } if (type === TYPES.ACCEPT) { call.phase = 'connecting'; setStatus(call, 'Соединяем…', 'connecting'); await onAccept(call); return; } if (type === TYPES.DECLINE_BUSY) { await finalizeCall(call, { localReasonCode: 'busy', debugReason: 'decline_or_busy' }); 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 { const pc = await ensurePeerConnection(call); await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); setStatus(call, 'Соединяем…', 'connecting'); 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 { const pc = await ensurePeerConnection(call); await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); setStatus(call, 'Соединяем…', 'connecting'); 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 pc = await ensurePeerConnection(call); await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data))); 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); await finalizeCall(call, { localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer', debugReason: 'hangup_by_user', notifyRemoteHangup: true, }); }