From 29a07a9a8bc121a45d5bdf3b9b02733728ebf5d052ceecceaa8cd4544adf3c6e Mon Sep 17 00:00:00 2001 From: AidarKC Date: Wed, 22 Apr 2026 15:46:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(call-ui):=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5=20=D0=BE=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0,=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D1=8B,=20=D0=B7=D0=B2=D1=83=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D1=85-=D0=B8=D1=81=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shine-UI/js/app.js | 2 + shine-UI/js/pages/chat-view.js | 25 +- shine-UI/js/services/call-service.js | 478 +++++++++++++++++++----- shine-UI/js/services/call-ui-service.js | 109 ++++++ shine-UI/js/state.js | 13 + shine-UI/styles/components.css | 54 +++ 6 files changed, 572 insertions(+), 109 deletions(-) create mode 100644 shine-UI/js/services/call-ui-service.js diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 63c1de6..92d3a68 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -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() { diff --git a/shine-UI/js/pages/chat-view.js b/shine-UI/js/pages/chat-view.js index a08da08..5456654 100644 --- a/shine-UI/js/pages/chat-view.js +++ b/shine-UI/js/pages/chat-view.js @@ -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); } }, diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index b2a081e..767ff8e 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -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, 'соединение установлено'); + if (!call.connectedAtMs) { + call.connectedAtMs = nowMs(); + } + setStatus(call, 'Разговор идёт', 'active'); void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`); + return; } - if (state === 'failed' || state === 'closed' || state === 'disconnected') { + 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); - await authService.callInviteBroadcast({ toLogin: cleanPeer, callId, type: TYPES.INVITE }); + 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 () => { - await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s'); - await finishCall(call, 'не ответили 20с', false); + if (!calls.has(callId)) return; + try { + await sendSignal(call, TYPES.TIMEOUT, 'timeout_20s'); + } 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, + }); } diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js new file mode 100644 index 0000000..e0b9370 --- /dev/null +++ b/shine-UI/js/services/call-ui-service.js @@ -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); +} diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index d06baa1..bef7896 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -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(); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 7428890..0060830 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -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; }