889 lines
26 KiB
JavaScript
889 lines
26 KiB
JavaScript
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 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);
|
||
}
|
||
|
||
async function closeMedia(call) {
|
||
try { call.pc?.close(); } catch {}
|
||
try { call.localStream?.getTracks()?.forEach((track) => track.stop()); } catch {}
|
||
call.pc = null;
|
||
call.localStream = null;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
setStatus(call, 'Разговор идёт', 'active');
|
||
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;
|
||
stream.getTracks().forEach((track) => {
|
||
track.enabled = !call.muted;
|
||
pc.addTrack(track, stream);
|
||
});
|
||
} 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();
|
||
}
|
||
|
||
export async function setMicMuted(muted) {
|
||
const call = getActiveCall();
|
||
if (!call) return;
|
||
call.muted = Boolean(muted);
|
||
try {
|
||
call.localStream?.getAudioTracks()?.forEach((track) => {
|
||
track.enabled = !call.muted;
|
||
});
|
||
} catch {}
|
||
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,
|
||
muted: false,
|
||
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,
|
||
muted: false,
|
||
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,
|
||
muted: false,
|
||
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,
|
||
muted: false,
|
||
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,
|
||
});
|
||
}
|