feat(call-ui): полноценное окно звонка, статусы, звуки и тех-история вызовов

This commit is contained in:
AidarKC 2026-04-22 15:46:45 +03:00
parent c824fb5e9b
commit 29a07a9a8b
6 changed files with 572 additions and 109 deletions

View File

@ -3,6 +3,7 @@ import { renderToolbar } from './components/toolbar.js';
import { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
import { initPwaPush } from './services/pwa-push-service.js';
import { initCallUiOverlay } from './services/call-ui-service.js';
import {
handleIncomingCallInvite,
handleIncomingCallSignal,
@ -117,6 +118,7 @@ let connectionState = '';
setClientErrorTransport((payload) => authService.reportClientError(payload));
initPwaInstallPromptHandling();
initCallUiOverlay();
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
function ensureConnectionStatusEl() {

View File

@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSystemChatMessage,
addOutgoingPendingMessage,
getChatMessages,
markChatRead,
@ -11,7 +11,7 @@ import {
authService,
state,
} from '../state.js';
import { startOutgoingCall, hangupActiveCall } from '../services/call-service.js';
import { startOutgoingCall } from '../services/call-service.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
@ -43,7 +43,8 @@ function renderLog(list, chatId) {
}
const bubble = document.createElement('div');
bubble.className = `bubble ${msg.from}`;
const bubbleKind = String(msg?.kind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
let text = msg.text || '';
if (msg.from === 'out') {
if (msg.secondTick) text += ' ✓✓';
@ -76,24 +77,14 @@ export function render({ navigate, route }) {
rightActions: [{
label: 'Позвонить',
onClick: async () => {
const confirmed = window.confirm('Позвонить этому пользователю?');
if (!confirmed) return;
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'}`);
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId);
}
},

View File

@ -1,4 +1,4 @@
import { addChatMessage, authService } from '../state.js';
import { addSystemChatMessage, authService } from '../state.js';
const TYPES = {
INVITE: 100,
@ -13,9 +13,16 @@ const TYPES = {
};
const calls = new Map();
const callStateListeners = new Set();
let activeCallId = '';
let debugReporter = null;
let audioContext = null;
let toneTimerId = null;
let toneName = '';
let toneFlip = false;
function nowMs() {
return Date.now();
}
@ -28,10 +35,233 @@ 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 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',
canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing',
};
}
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 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);
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 {
@ -44,48 +274,6 @@ async function emitDebug(call, 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 {
@ -143,22 +331,37 @@ async function ensurePeerConnection(call) {
pc.onconnectionstatechange = () => {
const state = pc.connectionState;
if (state === 'connected') {
setStatus(call, 'соединение установлено');
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
if (state === 'failed' || state === 'closed' || state === 'disconnected') {
setStatus(call, 'Разговор идёт', 'active');
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
return;
}
if (state === 'failed') {
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
finishCall(call, `state=${state}`, false);
void finalizeCall(call, { localReasonCode: call.connectedAtMs ? 'completed' : 'error', debugReason: 'failed' });
return;
}
if ((state === 'closed' || state === 'disconnected') && call.phase !== 'ended') {
void emitDebug(call, 'warn', 'peer_connection_closed', `state=${state}`);
if (call.connectedAtMs) {
void finalizeCall(call, { localReasonCode: 'completed', debugReason: state });
}
}
};
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = stream;
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
stream.getTracks().forEach((track) => {
track.enabled = !call.muted;
pc.addTrack(track, stream);
});
} catch (e) {
setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`);
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
throw e;
}
pc.ontrack = (evt) => {
@ -174,17 +377,68 @@ async function ensurePeerConnection(call) {
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));
setStatus(call, 'отправлен 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();
@ -197,12 +451,15 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
callId: cleanCallId,
peerLogin: cleanPeerLogin,
direction: 'in',
state: 'accepted',
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',
@ -212,7 +469,7 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
activeCallId = cleanCallId;
await emitDebug(call, 'info', 'debug_prepare_responder', `peerSessionId=${cleanPeerSessionId}`);
setStatus(call, 'debug: responder готов, ждём offer');
setStatus(call, 'Debug: responder готов, ждём offer', 'incoming');
}
export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin, peerSessionId }) {
@ -225,12 +482,15 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
callId: cleanCallId,
peerLogin: cleanPeerLogin,
direction: 'out',
state: 'accepted',
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',
@ -238,12 +498,13 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
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 finishCall(call, `debug start failed: ${toErrorText(error)}`, false);
await finalizeCall(call, { localReasonCode: 'error', debugReason: toErrorText(error) });
}
}
@ -251,9 +512,9 @@ export async function startOutgoingCall(peerLogin) {
const cleanPeer = String(peerLogin || '').trim();
if (!cleanPeer) return;
if (activeCallId) {
addChatMessage(cleanPeer, '[call] уже есть активный звонок');
return;
const active = getActiveCall();
if (active) {
throw new Error(`Уже есть активный звонок с ${active.peerLogin || 'другим пользователем'}`);
}
const callId = makeCallId();
@ -261,31 +522,43 @@ export async function startOutgoingCall(peerLogin) {
callId,
peerLogin: cleanPeer,
direction: 'out',
state: 'dialing',
phase: 'searching',
statusText: 'Ищем пользователя…',
remoteSessionId: '',
timers: {},
startedAtMs: nowMs(),
connectedAtMs: 0,
pc: null,
localStream: null,
muted: false,
debugMode: false,
debugRunId: '',
debugRole: '',
};
calls.set(callId, call);
activeCallId = callId;
setStatus(call, 'набираем...');
setStatus(call, 'Ищем пользователя…', 'searching');
call.timers.ack5s = setTimeout(() => {
if (call.state === 'dialing') {
finishCall(call, 'нет ответа 5с', false);
call.timers.ack10s = setTimeout(() => {
if (!calls.has(callId)) return;
if (call.phase === 'searching') {
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' });
}
}, 5000);
}, 10000);
call.timers.total22s = setTimeout(() => {
finishCall(call, 'таймаут 22с', false);
}, 22000);
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) {
@ -314,12 +587,15 @@ export async function handleIncomingCallInvite(evt) {
callId,
peerLogin: fromLogin,
direction: 'in',
state: 'incoming',
phase: 'incoming',
statusText: `Вам звонит ${fromLogin}`,
remoteSessionId: fromSessionId,
timers: {},
startedAtMs: nowMs(),
connectedAtMs: 0,
pc: null,
localStream: null,
muted: false,
debugMode: false,
debugRunId: '',
debugRole: '',
@ -328,27 +604,38 @@ export async function handleIncomingCallInvite(evt) {
}
activeCallId = callId;
setStatus(call, `входящий звонок от ${fromLogin}`);
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');
await finishCall(call, 'не ответили 20с', false);
} catch {}
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
}, 20000);
const accepted = window.confirm(`Вам звонит ${fromLogin}. Принять звонок?`);
if (!accepted) {
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
await finishCall(call, 'отклонен', false);
return;
}
call.state = 'accepted';
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');
setStatus(call, 'принят, ждём offer');
}
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) {
@ -365,30 +652,34 @@ export async function handleIncomingCallSignal(evt) {
if (!call.remoteSessionId) call.remoteSessionId = fromSessionId;
if (type === TYPES.RINGING) {
call.state = 'ringing';
setStatus(call, 'идут гудки');
if (call.direction === 'out' && call.phase === 'searching') {
setStatus(call, 'Вызываем…', 'ringing');
}
return;
}
if (type === TYPES.ACCEPT) {
call.state = 'accepted';
setStatus(call, 'звонок принят');
call.phase = 'connecting';
setStatus(call, 'Соединяем…', 'connecting');
await onAccept(call);
return;
}
if (type === TYPES.DECLINE_BUSY) {
await finishCall(call, 'занят/отклонено', false);
await finalizeCall(call, { localReasonCode: 'busy', debugReason: 'decline_or_busy' });
return;
}
if (type === TYPES.TIMEOUT) {
await finishCall(call, 'таймаут на стороне собеседника', false);
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'remote_timeout' });
return;
}
if (type === TYPES.HANGUP) {
await finishCall(call, 'собеседник завершил звонок', false);
await finalizeCall(call, {
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'remote_hangup',
});
return;
}
@ -399,11 +690,11 @@ export async function handleIncomingCallSignal(evt) {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
setStatus(call, 'получен offer, отправлен answer');
setStatus(call, 'Соединяем…', 'connecting');
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
} catch (error) {
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
throw error;
await finalizeCall(call, { localReasonCode: 'error', debugReason: `offer_failed:${toErrorText(error)}` });
}
return;
}
@ -412,11 +703,11 @@ export async function handleIncomingCallSignal(evt) {
try {
const pc = await ensurePeerConnection(call);
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
setStatus(call, 'получен answer');
setStatus(call, 'Соединяем…', 'connecting');
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
} catch (error) {
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
throw error;
await finalizeCall(call, { localReasonCode: 'error', debugReason: `answer_failed:${toErrorText(error)}` });
}
return;
}
@ -428,7 +719,6 @@ export async function handleIncomingCallSignal(evt) {
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
} catch (error) {
await emitDebug(call, 'error', 'ice_process_failed', toErrorText(error));
throw error;
}
}
}
@ -436,5 +726,9 @@ export async function handleIncomingCallSignal(evt) {
export async function hangupActiveCall() {
if (!activeCallId) return;
const call = getCall(activeCallId);
await finishCall(call, 'сброшен пользователем', true);
await finalizeCall(call, {
localReasonCode: call?.connectedAtMs ? 'completed' : 'no_answer',
debugReason: 'hangup_by_user',
notifyRemoteHangup: true,
});
}

View File

@ -0,0 +1,109 @@
import {
acceptIncomingCall,
declineIncomingCall,
hangupActiveCall,
setMicMuted,
subscribeCallState,
} from './call-service.js';
let shellEl = null;
let panelEl = null;
let titleEl = null;
let statusEl = null;
let muteBtn = null;
let acceptBtn = null;
let declineBtn = null;
let hangupBtn = null;
let unbind = null;
function ensureUi() {
if (shellEl) return;
shellEl = document.createElement('section');
shellEl.className = 'call-overlay';
shellEl.hidden = true;
panelEl = document.createElement('div');
panelEl.className = 'call-overlay-panel';
titleEl = document.createElement('h2');
titleEl.className = 'call-overlay-title';
statusEl = document.createElement('div');
statusEl.className = 'call-overlay-status';
const controls = document.createElement('div');
controls.className = 'call-overlay-controls';
muteBtn = document.createElement('button');
muteBtn.type = 'button';
muteBtn.className = 'secondary-btn';
muteBtn.textContent = 'Микрофон';
muteBtn.addEventListener('click', async () => {
const nowMuted = muteBtn.dataset.muted === '1';
await setMicMuted(!nowMuted);
});
acceptBtn = document.createElement('button');
acceptBtn.type = 'button';
acceptBtn.className = 'primary-btn';
acceptBtn.textContent = 'Ответить';
acceptBtn.addEventListener('click', async () => {
await acceptIncomingCall();
});
declineBtn = document.createElement('button');
declineBtn.type = 'button';
declineBtn.className = 'ghost-btn';
declineBtn.textContent = 'Отклонить';
declineBtn.addEventListener('click', async () => {
await declineIncomingCall();
});
hangupBtn = document.createElement('button');
hangupBtn.type = 'button';
hangupBtn.className = 'destructive-btn';
hangupBtn.textContent = 'Положить';
hangupBtn.addEventListener('click', async () => {
await hangupActiveCall();
});
controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn);
panelEl.append(titleEl, statusEl, controls);
shellEl.append(panelEl);
document.body.append(shellEl);
}
function applyCallState(snapshot) {
ensureUi();
if (!snapshot) {
shellEl.hidden = true;
return;
}
shellEl.hidden = false;
titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
statusEl.textContent = snapshot.statusText || '';
const muted = Boolean(snapshot.muted);
muteBtn.dataset.muted = muted ? '1' : '0';
muteBtn.textContent = muted ? 'Микрофон выкл' : 'Микрофон вкл';
muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute;
acceptBtn.hidden = !snapshot.canAnswer;
declineBtn.hidden = !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup;
}
export function initCallUiOverlay() {
ensureUi();
if (unbind) {
unbind();
unbind = null;
}
unbind = subscribeCallState(applyCallState);
}

View File

@ -325,6 +325,19 @@ export function addChatMessage(chatId, text) {
getChatMessages(chatId).push({ from: 'out', text: message, firstTick: false, secondTick: false, unread: false });
}
export function addSystemChatMessage(chatId, text, { from = 'out', kind = 'system' } = {}) {
const message = String(text || '').trim();
if (!message) return;
getChatMessages(chatId).push({
from: from === 'in' ? 'in' : 'out',
text: message,
kind: String(kind || 'system'),
unread: from === 'in',
firstTick: from !== 'in',
secondTick: false,
});
}
export function addIncomingMessage(chatId, text, messageId = '') {
const msg = text?.trim();

View File

@ -720,12 +720,66 @@
border-top-right-radius: 6px;
}
.bubble.call-tech {
max-width: 90%;
justify-self: center;
border-radius: 10px;
border: 1px solid rgba(131, 162, 223, 0.32);
background: rgba(40, 55, 84, 0.55);
color: #dce8ff;
font-size: 12px;
}
.chat-input {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.call-overlay[hidden] {
display: none;
}
.call-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
background: rgba(6, 10, 16, 0.55);
backdrop-filter: blur(4px);
}
.call-overlay-panel {
width: min(520px, 100%);
border-radius: 18px;
padding: 16px;
border: 1px solid rgba(112, 146, 214, 0.38);
background: linear-gradient(180deg, rgba(15, 27, 51, 0.96), rgba(8, 15, 31, 0.98));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.46);
display: grid;
gap: 10px;
}
.call-overlay-title {
margin: 0;
font-size: 20px;
color: #eff4ff;
}
.call-overlay-status {
color: #c8d7f6;
font-size: 14px;
}
.call-overlay-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.pwa-diag-list {
gap: 8px;
}