SHiNE-server/shine-UI/js/services/call-service.js

441 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { addChatMessage, 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();
let activeCallId = '';
let debugReporter = null;
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 toErrorText(error) {
return error?.message || String(error || 'unknown');
}
async function emitDebug(call, level, message, details = '') {
if (!call?.debugRunId || typeof debugReporter !== 'function') return;
try {
await debugReporter({
runId: call.debugRunId,
level,
message,
details,
});
} catch {}
}
function setStatus(call, text) {
call.status = text;
addChatMessage(call.peerLogin || 'debug-peer', `[call] ${text}`);
void emitDebug(call, 'info', `call_status: ${text}`, `callId=${call.callId}`);
}
function cleanupTimers(call) {
if (call.timers?.ack5s) clearTimeout(call.timers.ack5s);
if (call.timers?.total22s) clearTimeout(call.timers.total22s);
if (call.timers?.incoming20s) clearTimeout(call.timers.incoming20s);
}
async function closeMedia(call) {
try { call.pc?.close(); } catch {}
try { call.localStream?.getTracks()?.forEach((t) => t.stop()); } catch {}
call.pc = null;
call.localStream = null;
}
async function finishCall(call, reason, notifyRemote = false) {
if (!call) return;
cleanupTimers(call);
if (notifyRemote && call.remoteSessionId) {
try {
await authService.callSignalToSession({
toLogin: call.peerLogin,
targetSessionId: call.remoteSessionId,
callId: call.callId,
type: TYPES.HANGUP,
data: '',
});
} catch {}
}
await closeMedia(call);
setStatus(call, `завершен: ${reason}`);
if (String(reason || '').toLowerCase().includes('connected')) {
await emitDebug(call, 'info', 'debug_connection_success', reason);
}
calls.delete(call.callId);
if (activeCallId === call.callId) activeCallId = '';
}
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 pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
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') {
setStatus(call, 'соединение установлено');
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
}
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
finishCall(call, `state=${state}`, false);
}
};
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = stream;
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
} catch (e) {
setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`);
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(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);
const pc = await ensurePeerConnection(call);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
setStatus(call, 'отправлен offer');
}
export function setCallDebugReporter(fn) {
debugReporter = typeof fn === 'function' ? fn : null;
}
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',
state: 'accepted',
remoteSessionId: cleanPeerSessionId,
timers: {},
startedAtMs: nowMs(),
pc: null,
localStream: null,
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');
}
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',
state: 'accepted',
remoteSessionId: cleanPeerSessionId,
timers: {},
startedAtMs: nowMs(),
pc: null,
localStream: null,
debugMode: true,
debugRunId: String(runId || '').trim(),
debugRole: 'initiator',
};
calls.set(cleanCallId, call);
activeCallId = cleanCallId;
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 finishCall(call, `debug start failed: ${toErrorText(error)}`, false);
}
}
export async function startOutgoingCall(peerLogin) {
const cleanPeer = String(peerLogin || '').trim();
if (!cleanPeer) return;
if (activeCallId) {
addChatMessage(cleanPeer, '[call] уже есть активный звонок');
return;
}
const callId = makeCallId();
const call = {
callId,
peerLogin: cleanPeer,
direction: 'out',
state: 'dialing',
remoteSessionId: '',
timers: {},
startedAtMs: nowMs(),
pc: null,
localStream: null,
debugMode: false,
debugRunId: '',
debugRole: '',
};
calls.set(callId, call);
activeCallId = callId;
setStatus(call, 'набираем...');
call.timers.ack5s = setTimeout(() => {
if (call.state === 'dialing') {
finishCall(call, 'нет ответа 5с', false);
}
}, 5000);
call.timers.total22s = setTimeout(() => {
finishCall(call, 'таймаут 22с', false);
}, 22000);
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
}
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',
state: 'incoming',
remoteSessionId: fromSessionId,
timers: {},
startedAtMs: nowMs(),
pc: null,
localStream: null,
debugMode: false,
debugRunId: '',
debugRole: '',
};
calls.set(callId, call);
}
activeCallId = callId;
setStatus(call, `входящий звонок от ${fromLogin}`);
try {
await sendSignal(call, TYPES.RINGING, 'ringing');
} catch {}
call.timers.incoming20s = setTimeout(async () => {
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
await finishCall(call, 'не ответили 20с', false);
}, 20000);
const accepted = window.confirm(`Вам звонит ${fromLogin}. Принять звонок?`);
if (!accepted) {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
await finishCall(call, 'отклонен', false);
return;
}
call.state = 'accepted';
await sendSignal(call, TYPES.ACCEPT, 'accept');
setStatus(call, 'принят, ждём offer');
}
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) {
call.state = 'ringing';
setStatus(call, 'идут гудки');
return;
}
if (type === TYPES.ACCEPT) {
call.state = 'accepted';
setStatus(call, 'звонок принят');
await onAccept(call);
return;
}
if (type === TYPES.DECLINE_BUSY) {
await finishCall(call, 'занят/отклонено', false);
return;
}
if (type === TYPES.TIMEOUT) {
await finishCall(call, 'таймаут на стороне собеседника', false);
return;
}
if (type === TYPES.HANGUP) {
await finishCall(call, 'собеседник завершил звонок', false);
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, 'получен offer, отправлен answer');
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
} catch (error) {
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
throw error;
}
return;
}
if (type === TYPES.ANSWER) {
try {
const pc = await ensurePeerConnection(call);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
setStatus(call, 'получен answer');
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
} catch (error) {
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
throw 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));
throw error;
}
}
}
export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
await finishCall(call, 'сброшен пользователем', true);
}