Add MVP call signaling API and browser call flow

This commit is contained in:
ai5590
2026-04-15 09:33:37 +03:00
parent 1ee2a1cf62
commit eaad476bf5
12 changed files with 665 additions and 2 deletions
+10
View File
@@ -2,6 +2,7 @@ import { navigate, getRoute, PRE_AUTH_PAGES } from './router.js';
import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { handleIncomingCallInvite, handleIncomingCallSignal } from './services/call-service.js';
import {
authService,
authorizeSession,
@@ -223,6 +224,15 @@ async function init() {
try { await authService.ackIncomingMessage(eventId, messageId); } catch {}
}
});
authService.onEvent('IncomingCallInvite', async (evt) => {
try { await handleIncomingCallInvite(evt); } catch {}
});
authService.onEvent('IncomingCallSignal', async (evt) => {
try { await handleIncomingCallSignal(evt); } catch {}
});
await tryAutoLogin();
if (state.session.isAuthorized) {
await initPwaPush({ authService });
+20 -2
View File
@@ -1,6 +1,7 @@
import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import { addChatMessage, getChatMessages, authService } from '../state.js';
import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
@@ -33,10 +34,27 @@ export function render({ navigate, route }) {
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [{
label: 'Позвонить',
onClick: () => {
onClick: async () => {
const confirmed = window.confirm('Позвонить этому пользователю?');
if (!confirmed) return;
window.alert('Функция пока не реализована');
try {
await startOutgoingCall(chatId);
renderLog(log, chatId);
} catch (e) {
addChatMessage(chatId, `[call] Ошибка звонка: ${e.message || 'unknown'}`);
renderLog(log, chatId);
}
},
}, {
label: 'Сброс',
onClick: async () => {
try {
await hangupActiveCall();
renderLog(log, chatId);
} catch (e) {
addChatMessage(chatId, `[call] Ошибка сброса: ${e.message || 'unknown'}`);
renderLog(log, chatId);
}
},
}],
})
+12
View File
@@ -372,6 +372,18 @@ export class AuthService {
return response.payload || {};
}
async callInviteBroadcast({ toLogin, callId, type = 100 }) {
const response = await this.ws.request('CallInviteBroadcast', { toLogin, callId, type });
if (response.status !== 200) throw opError('CallInviteBroadcast', response);
return response.payload || {};
}
async callSignalToSession({ toLogin, targetSessionId, callId, type, data = '' }) {
const response = await this.ws.request('CallSignalToSession', { toLogin, targetSessionId, callId, type, data });
if (response.status !== 200) throw opError('CallSignalToSession', response);
return response.payload || {};
}
async listContacts() {
const response = await this.ws.request('ListContacts', {});
if (response.status !== 200) throw opError('ListContacts', response);
+302
View File
@@ -0,0 +1,302 @@
import { addChatMessage, state, 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 = '';
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 setStatus(call, text) {
call.status = text;
addChatMessage(call.peerLogin, `[call] ${text}`);
}
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}`);
calls.delete(call.callId);
if (activeCallId === call.callId) activeCallId = '';
}
async function ensurePeerConnection(call) {
if (call.pc) return call.pc;
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
pc.onicecandidate = async (event) => {
if (!event.candidate || !call.remoteSessionId) return;
try {
await authService.callSignalToSession({
toLogin: call.peerLogin,
targetSessionId: call.remoteSessionId,
callId: call.callId,
type: TYPES.ICE,
data: JSON.stringify(event.candidate),
});
} catch {}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'connected') {
setStatus(call, 'соединение установлено');
}
if (pc.connectionState === 'failed' || pc.connectionState === 'closed' || pc.connectionState === 'disconnected') {
finishCall(call, `state=${pc.connectionState}`, 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'}`);
}
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 sendSignal(call, type, data = '') {
if (!call.remoteSessionId) return;
await authService.callSignalToSession({
toLogin: call.peerLogin,
targetSessionId: call.remoteSessionId,
callId: call.callId,
type,
data,
});
}
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 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,
};
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,
};
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) {
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');
return;
}
if (type === TYPES.ANSWER) {
const pc = await ensurePeerConnection(call);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
setStatus(call, 'получен answer');
return;
}
if (type === TYPES.ICE) {
const pc = await ensurePeerConnection(call);
await pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data)));
}
}
export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
await finishCall(call, 'сброшен пользователем', true);
}