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 { captureClientError, setClientErrorTransport } from './services/client-error-reporter.js';
|
||||||
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
import { initPwaInstallPromptHandling } from './services/pwa-install-service.js';
|
||||||
import { initPwaPush } from './services/pwa-push-service.js';
|
import { initPwaPush } from './services/pwa-push-service.js';
|
||||||
|
import { initCallUiOverlay } from './services/call-ui-service.js';
|
||||||
import {
|
import {
|
||||||
handleIncomingCallInvite,
|
handleIncomingCallInvite,
|
||||||
handleIncomingCallSignal,
|
handleIncomingCallSignal,
|
||||||
@ -117,6 +118,7 @@ let connectionState = '';
|
|||||||
|
|
||||||
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
setClientErrorTransport((payload) => authService.reportClientError(payload));
|
||||||
initPwaInstallPromptHandling();
|
initPwaInstallPromptHandling();
|
||||||
|
initCallUiOverlay();
|
||||||
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
setCallDebugReporter((payload) => authService.reportClientDebug(payload));
|
||||||
|
|
||||||
function ensureConnectionStatusEl() {
|
function ensureConnectionStatusEl() {
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { renderHeader } from '../components/header.js';
|
|||||||
import { directMessages } from '../mock-data.js';
|
import { directMessages } from '../mock-data.js';
|
||||||
import {
|
import {
|
||||||
addAppLogEntry,
|
addAppLogEntry,
|
||||||
addChatMessage,
|
addSystemChatMessage,
|
||||||
addOutgoingPendingMessage,
|
addOutgoingPendingMessage,
|
||||||
getChatMessages,
|
getChatMessages,
|
||||||
markChatRead,
|
markChatRead,
|
||||||
@ -11,7 +11,7 @@ import {
|
|||||||
authService,
|
authService,
|
||||||
state,
|
state,
|
||||||
} from '../state.js';
|
} 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: 'Чат' };
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||||||
|
|
||||||
@ -43,7 +43,8 @@ function renderLog(list, chatId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bubble = document.createElement('div');
|
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 || '';
|
let text = msg.text || '';
|
||||||
if (msg.from === 'out') {
|
if (msg.from === 'out') {
|
||||||
if (msg.secondTick) text += ' ✓✓';
|
if (msg.secondTick) text += ' ✓✓';
|
||||||
@ -76,24 +77,14 @@ export function render({ navigate, route }) {
|
|||||||
rightActions: [{
|
rightActions: [{
|
||||||
label: 'Позвонить',
|
label: 'Позвонить',
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const confirmed = window.confirm('Позвонить этому пользователю?');
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
try {
|
||||||
await startOutgoingCall(chatId);
|
await startOutgoingCall(chatId);
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addChatMessage(chatId, `[call] Ошибка звонка: ${e.message || 'unknown'}`);
|
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||||
renderLog(log, chatId);
|
from: 'out',
|
||||||
}
|
kind: 'call-tech',
|
||||||
},
|
});
|
||||||
}, {
|
|
||||||
label: 'Сброс',
|
|
||||||
onClick: async () => {
|
|
||||||
try {
|
|
||||||
await hangupActiveCall();
|
|
||||||
renderLog(log, chatId);
|
|
||||||
} catch (e) {
|
|
||||||
addChatMessage(chatId, `[call] Ошибка сброса: ${e.message || 'unknown'}`);
|
|
||||||
renderLog(log, chatId);
|
renderLog(log, chatId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { addChatMessage, authService } from '../state.js';
|
import { addSystemChatMessage, authService } from '../state.js';
|
||||||
|
|
||||||
const TYPES = {
|
const TYPES = {
|
||||||
INVITE: 100,
|
INVITE: 100,
|
||||||
@ -13,9 +13,16 @@ const TYPES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calls = new Map();
|
const calls = new Map();
|
||||||
|
const callStateListeners = new Set();
|
||||||
|
|
||||||
let activeCallId = '';
|
let activeCallId = '';
|
||||||
let debugReporter = null;
|
let debugReporter = null;
|
||||||
|
|
||||||
|
let audioContext = null;
|
||||||
|
let toneTimerId = null;
|
||||||
|
let toneName = '';
|
||||||
|
let toneFlip = false;
|
||||||
|
|
||||||
function nowMs() {
|
function nowMs() {
|
||||||
return Date.now();
|
return Date.now();
|
||||||
}
|
}
|
||||||
@ -28,10 +35,233 @@ function getCall(callId) {
|
|||||||
return calls.get(callId) || null;
|
return calls.get(callId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActiveCall() {
|
||||||
|
if (!activeCallId) return null;
|
||||||
|
return getCall(activeCallId);
|
||||||
|
}
|
||||||
|
|
||||||
function toErrorText(error) {
|
function toErrorText(error) {
|
||||||
return error?.message || String(error || 'unknown');
|
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 = '') {
|
async function emitDebug(call, level, message, details = '') {
|
||||||
if (!call?.debugRunId || typeof debugReporter !== 'function') return;
|
if (!call?.debugRunId || typeof debugReporter !== 'function') return;
|
||||||
try {
|
try {
|
||||||
@ -44,48 +274,6 @@ async function emitDebug(call, level, message, details = '') {
|
|||||||
} catch {}
|
} 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 = '') {
|
async function sendSignal(call, type, data = '') {
|
||||||
if (!call.remoteSessionId) return;
|
if (!call.remoteSessionId) return;
|
||||||
try {
|
try {
|
||||||
@ -143,22 +331,37 @@ async function ensurePeerConnection(call) {
|
|||||||
pc.onconnectionstatechange = () => {
|
pc.onconnectionstatechange = () => {
|
||||||
const state = pc.connectionState;
|
const state = pc.connectionState;
|
||||||
if (state === 'connected') {
|
if (state === 'connected') {
|
||||||
setStatus(call, 'соединение установлено');
|
if (!call.connectedAtMs) {
|
||||||
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
|
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}`);
|
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 {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
call.localStream = stream;
|
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) {
|
} catch (e) {
|
||||||
setStatus(call, `нет доступа к микрофону: ${e?.message || 'unknown'}`);
|
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
|
||||||
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
|
await emitDebug(call, 'warn', 'microphone_access_failed', toErrorText(e));
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
pc.ontrack = (evt) => {
|
pc.ontrack = (evt) => {
|
||||||
@ -174,17 +377,68 @@ async function ensurePeerConnection(call) {
|
|||||||
|
|
||||||
async function onAccept(call) {
|
async function onAccept(call) {
|
||||||
cleanupTimers(call);
|
cleanupTimers(call);
|
||||||
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
const pc = await ensurePeerConnection(call);
|
const pc = await ensurePeerConnection(call);
|
||||||
const offer = await pc.createOffer();
|
const offer = await pc.createOffer();
|
||||||
await pc.setLocalDescription(offer);
|
await pc.setLocalDescription(offer);
|
||||||
await sendSignal(call, TYPES.OFFER, JSON.stringify(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) {
|
export function setCallDebugReporter(fn) {
|
||||||
debugReporter = typeof fn === 'function' ? fn : null;
|
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 }) {
|
export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) {
|
||||||
const cleanCallId = String(callId || '').trim();
|
const cleanCallId = String(callId || '').trim();
|
||||||
const cleanPeerLogin = String(peerLogin || '').trim();
|
const cleanPeerLogin = String(peerLogin || '').trim();
|
||||||
@ -197,12 +451,15 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|||||||
callId: cleanCallId,
|
callId: cleanCallId,
|
||||||
peerLogin: cleanPeerLogin,
|
peerLogin: cleanPeerLogin,
|
||||||
direction: 'in',
|
direction: 'in',
|
||||||
state: 'accepted',
|
phase: 'incoming',
|
||||||
|
statusText: 'Debug: responder ждёт offer',
|
||||||
remoteSessionId: cleanPeerSessionId,
|
remoteSessionId: cleanPeerSessionId,
|
||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
muted: false,
|
||||||
debugMode: true,
|
debugMode: true,
|
||||||
debugRunId: String(runId || '').trim(),
|
debugRunId: String(runId || '').trim(),
|
||||||
debugRole: 'responder',
|
debugRole: 'responder',
|
||||||
@ -212,7 +469,7 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
|
|||||||
|
|
||||||
activeCallId = cleanCallId;
|
activeCallId = cleanCallId;
|
||||||
await emitDebug(call, 'info', 'debug_prepare_responder', `peerSessionId=${cleanPeerSessionId}`);
|
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 }) {
|
export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin, peerSessionId }) {
|
||||||
@ -225,12 +482,15 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|||||||
callId: cleanCallId,
|
callId: cleanCallId,
|
||||||
peerLogin: cleanPeerLogin,
|
peerLogin: cleanPeerLogin,
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
state: 'accepted',
|
phase: 'connecting',
|
||||||
|
statusText: 'Debug: старт соединения',
|
||||||
remoteSessionId: cleanPeerSessionId,
|
remoteSessionId: cleanPeerSessionId,
|
||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
muted: false,
|
||||||
debugMode: true,
|
debugMode: true,
|
||||||
debugRunId: String(runId || '').trim(),
|
debugRunId: String(runId || '').trim(),
|
||||||
debugRole: 'initiator',
|
debugRole: 'initiator',
|
||||||
@ -238,12 +498,13 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
|
|||||||
|
|
||||||
calls.set(cleanCallId, call);
|
calls.set(cleanCallId, call);
|
||||||
activeCallId = cleanCallId;
|
activeCallId = cleanCallId;
|
||||||
|
notifyCallState();
|
||||||
await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`);
|
await emitDebug(call, 'info', 'debug_start_initiator', `peerSessionId=${cleanPeerSessionId}`);
|
||||||
try {
|
try {
|
||||||
await onAccept(call);
|
await onAccept(call);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'debug_initiator_start_failed', toErrorText(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();
|
const cleanPeer = String(peerLogin || '').trim();
|
||||||
if (!cleanPeer) return;
|
if (!cleanPeer) return;
|
||||||
|
|
||||||
if (activeCallId) {
|
const active = getActiveCall();
|
||||||
addChatMessage(cleanPeer, '[call] уже есть активный звонок');
|
if (active) {
|
||||||
return;
|
throw new Error(`Уже есть активный звонок с ${active.peerLogin || 'другим пользователем'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const callId = makeCallId();
|
const callId = makeCallId();
|
||||||
@ -261,31 +522,43 @@ export async function startOutgoingCall(peerLogin) {
|
|||||||
callId,
|
callId,
|
||||||
peerLogin: cleanPeer,
|
peerLogin: cleanPeer,
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
state: 'dialing',
|
phase: 'searching',
|
||||||
|
statusText: 'Ищем пользователя…',
|
||||||
remoteSessionId: '',
|
remoteSessionId: '',
|
||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
muted: false,
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
debugRunId: '',
|
debugRunId: '',
|
||||||
debugRole: '',
|
debugRole: '',
|
||||||
};
|
};
|
||||||
calls.set(callId, call);
|
calls.set(callId, call);
|
||||||
activeCallId = callId;
|
activeCallId = callId;
|
||||||
setStatus(call, 'набираем...');
|
setStatus(call, 'Ищем пользователя…', 'searching');
|
||||||
|
|
||||||
call.timers.ack5s = setTimeout(() => {
|
call.timers.ack10s = setTimeout(() => {
|
||||||
if (call.state === 'dialing') {
|
if (!calls.has(callId)) return;
|
||||||
finishCall(call, 'нет ответа 5с', false);
|
if (call.phase === 'searching') {
|
||||||
|
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'no_ack_10s' });
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 10000);
|
||||||
|
|
||||||
call.timers.total22s = setTimeout(() => {
|
call.timers.total35s = setTimeout(() => {
|
||||||
finishCall(call, 'таймаут 22с', false);
|
if (!calls.has(callId)) return;
|
||||||
}, 22000);
|
if (!call.connectedAtMs) {
|
||||||
|
void finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'total_timeout_35s' });
|
||||||
|
}
|
||||||
|
}, 35000);
|
||||||
|
|
||||||
|
try {
|
||||||
await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE });
|
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) {
|
export async function handleIncomingCallInvite(evt) {
|
||||||
@ -314,12 +587,15 @@ export async function handleIncomingCallInvite(evt) {
|
|||||||
callId,
|
callId,
|
||||||
peerLogin: fromLogin,
|
peerLogin: fromLogin,
|
||||||
direction: 'in',
|
direction: 'in',
|
||||||
state: 'incoming',
|
phase: 'incoming',
|
||||||
|
statusText: `Вам звонит ${fromLogin}`,
|
||||||
remoteSessionId: fromSessionId,
|
remoteSessionId: fromSessionId,
|
||||||
timers: {},
|
timers: {},
|
||||||
startedAtMs: nowMs(),
|
startedAtMs: nowMs(),
|
||||||
|
connectedAtMs: 0,
|
||||||
pc: null,
|
pc: null,
|
||||||
localStream: null,
|
localStream: null,
|
||||||
|
muted: false,
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
debugRunId: '',
|
debugRunId: '',
|
||||||
debugRole: '',
|
debugRole: '',
|
||||||
@ -328,27 +604,38 @@ export async function handleIncomingCallInvite(evt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeCallId = callId;
|
activeCallId = callId;
|
||||||
setStatus(call, `входящий звонок от ${fromLogin}`);
|
setStatus(call, `Вам звонит ${fromLogin}`, 'incoming');
|
||||||
|
ensureIncomingNotification(fromLogin);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendSignal(call, TYPES.RINGING, 'ringing');
|
await sendSignal(call, TYPES.RINGING, 'ringing');
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
call.timers.incoming20s = setTimeout(async () => {
|
call.timers.incoming20s = setTimeout(async () => {
|
||||||
|
if (!calls.has(callId)) return;
|
||||||
|
try {
|
||||||
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s');
|
||||||
await finishCall(call, 'не ответили 20с', false);
|
} catch {}
|
||||||
|
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'incoming_timeout_20s' });
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
}
|
||||||
|
|
||||||
const accepted = window.confirm(`Вам звонит ${fromLogin}. Принять звонок?`);
|
export async function acceptIncomingCall() {
|
||||||
if (!accepted) {
|
const call = getActiveCall();
|
||||||
await sendSignal(call, TYPES.DECLINE_BUSY, 'decline');
|
if (!call || call.direction !== 'in' || call.phase !== 'incoming') return;
|
||||||
await finishCall(call, 'отклонен', false);
|
call.phase = 'connecting';
|
||||||
return;
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
}
|
cleanupTimers(call);
|
||||||
|
|
||||||
call.state = 'accepted';
|
|
||||||
await sendSignal(call, TYPES.ACCEPT, 'accept');
|
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) {
|
export async function handleIncomingCallSignal(evt) {
|
||||||
@ -365,30 +652,34 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
if (!call.remoteSessionId) call.remoteSessionId = fromSessionId;
|
if (!call.remoteSessionId) call.remoteSessionId = fromSessionId;
|
||||||
|
|
||||||
if (type === TYPES.RINGING) {
|
if (type === TYPES.RINGING) {
|
||||||
call.state = 'ringing';
|
if (call.direction === 'out' && call.phase === 'searching') {
|
||||||
setStatus(call, 'идут гудки');
|
setStatus(call, 'Вызываем…', 'ringing');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === TYPES.ACCEPT) {
|
if (type === TYPES.ACCEPT) {
|
||||||
call.state = 'accepted';
|
call.phase = 'connecting';
|
||||||
setStatus(call, 'звонок принят');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
await onAccept(call);
|
await onAccept(call);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === TYPES.DECLINE_BUSY) {
|
if (type === TYPES.DECLINE_BUSY) {
|
||||||
await finishCall(call, 'занят/отклонено', false);
|
await finalizeCall(call, { localReasonCode: 'busy', debugReason: 'decline_or_busy' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === TYPES.TIMEOUT) {
|
if (type === TYPES.TIMEOUT) {
|
||||||
await finishCall(call, 'таймаут на стороне собеседника', false);
|
await finalizeCall(call, { localReasonCode: 'no_answer', debugReason: 'remote_timeout' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === TYPES.HANGUP) {
|
if (type === TYPES.HANGUP) {
|
||||||
await finishCall(call, 'собеседник завершил звонок', false);
|
await finalizeCall(call, {
|
||||||
|
localReasonCode: call.connectedAtMs ? 'completed' : 'no_answer',
|
||||||
|
debugReason: 'remote_hangup',
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -399,11 +690,11 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
const answer = await pc.createAnswer();
|
const answer = await pc.createAnswer();
|
||||||
await pc.setLocalDescription(answer);
|
await pc.setLocalDescription(answer);
|
||||||
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
|
||||||
setStatus(call, 'получен offer, отправлен answer');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
|
await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error));
|
||||||
throw error;
|
await finalizeCall(call, { localReasonCode: 'error', debugReason: `offer_failed:${toErrorText(error)}` });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -412,11 +703,11 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
try {
|
try {
|
||||||
const pc = await ensurePeerConnection(call);
|
const pc = await ensurePeerConnection(call);
|
||||||
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
|
||||||
setStatus(call, 'получен answer');
|
setStatus(call, 'Соединяем…', 'connecting');
|
||||||
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
|
await emitDebug(call, 'info', 'answer_processed', 'remote description set');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
|
await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error));
|
||||||
throw error;
|
await finalizeCall(call, { localReasonCode: 'error', debugReason: `answer_failed:${toErrorText(error)}` });
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -428,7 +719,6 @@ export async function handleIncomingCallSignal(evt) {
|
|||||||
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
|
await emitDebug(call, 'info', 'ice_processed', 'candidate added');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await emitDebug(call, 'error', 'ice_process_failed', toErrorText(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() {
|
export async function hangupActiveCall() {
|
||||||
if (!activeCallId) return;
|
if (!activeCallId) return;
|
||||||
const call = getCall(activeCallId);
|
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 });
|
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 = '') {
|
export function addIncomingMessage(chatId, text, messageId = '') {
|
||||||
const msg = text?.trim();
|
const msg = text?.trim();
|
||||||
|
|||||||
@ -720,12 +720,66 @@
|
|||||||
border-top-right-radius: 6px;
|
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 {
|
.chat-input {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
gap: 8px;
|
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 {
|
.pwa-diag-list {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user