feat(call-ui): полноценное окно звонка, статусы, звуки и тех-история вызовов
This commit is contained in:
parent
c824fb5e9b
commit
29a07a9a8b
@ -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() {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
109
shine-UI/js/services/call-ui-service.js
Normal file
109
shine-UI/js/services/call-ui-service.js
Normal 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);
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user