diff --git a/VERSION.properties b/VERSION.properties index 9ee98741..04751ddd 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.347 +client.version=1.2.348 server.version=1.2.319 diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 2bd8e718..64ef0549 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -554,6 +554,8 @@ function getCallStateSnapshot() { direction: call.direction || 'out', phase: callPhase, statusText: call.statusText || '', + startedAtMs: Number(call.startedAtMs || 0), + connectedAtMs: Number(call.connectedAtMs || 0), muted: Boolean(call.muted), canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', @@ -658,6 +660,7 @@ function buildCallFactsLine(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)), timeline: buildTimelineSummary(call), ...extra, }; @@ -666,6 +669,60 @@ function buildCallFactsLine(call, extra = {}) { .join(', '); } +function buildMediaSnapshot(call) { + const localStream = call?.localStream || null; + const localTracks = localStream?.getTracks?.() || []; + const remoteAudio = call?.remoteAudio || null; + const remoteStream = remoteAudio?.srcObject || null; + const remoteTracks = remoteStream?.getTracks?.() || []; + return { + hasLocalStream: Boolean(localStream), + localTrackCount: localTracks.length, + localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0, + localTrackStates: localTracks.map((track) => ({ + kind: String(track?.kind || ''), + enabled: Boolean(track?.enabled), + muted: Boolean(track?.muted), + readyState: String(track?.readyState || ''), + label: String(track?.label || ''), + })), + hasRemoteAudio: Boolean(remoteAudio), + hasRemoteAudioSrcObject: Boolean(remoteStream), + remoteTrackCount: remoteTracks.length, + remoteTrackStates: remoteTracks.map((track) => ({ + kind: String(track?.kind || ''), + enabled: Boolean(track?.enabled), + muted: Boolean(track?.muted), + readyState: String(track?.readyState || ''), + label: String(track?.label || ''), + })), + }; +} + +function summarizeMediaSnapshot(snapshot) { + if (!snapshot) return ''; + const localSummary = Array.isArray(snapshot.localTrackStates) + ? snapshot.localTrackStates.map((track) => ( + `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` + )).join('|') + : ''; + const remoteSummary = Array.isArray(snapshot.remoteTrackStates) + ? snapshot.remoteTrackStates.map((track) => ( + `${track.kind}:${track.readyState}:${track.enabled ? 'on' : 'off'}:${track.muted ? 'muted' : 'live'}` + )).join('|') + : ''; + return [ + `localStream=${snapshot.hasLocalStream ? 1 : 0}`, + `localTracks=${Number(snapshot.localTrackCount || 0)}`, + `localAudio=${Number(snapshot.localAudioTracksCount || 0)}`, + `localStates=${localSummary || '-'}`, + `remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`, + `remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`, + `remoteTracks=${Number(snapshot.remoteTrackCount || 0)}`, + `remoteStates=${remoteSummary || '-'}`, + ].join(';'); +} + function getCallDiagnosticsContext(call) { const pc = call?.pc || null; const nav = typeof navigator !== 'undefined' ? navigator : null; @@ -686,6 +743,7 @@ function getCallDiagnosticsContext(call) { const iceGatheringState = pc?.iceGatheringState || ''; const currentLocalDescType = pc?.localDescription?.type || ''; const currentRemoteDescType = pc?.remoteDescription?.type || ''; + const mediaSnapshot = buildMediaSnapshot(call); return { remoteSessionIdPresent: Boolean(call?.remoteSessionId), @@ -707,6 +765,7 @@ function getCallDiagnosticsContext(call) { localAudioTracksCount: localAudioTracks.length, localAudioTracksEnabledCount: enabledLocalAudioTracks, localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'), + localTrackStates: summarizeMediaSnapshot(mediaSnapshot), hasPeerConnection: Boolean(pc), pcConnectionState: pc?.connectionState || '', pcIceConnectionState: pc?.iceConnectionState || '', @@ -808,6 +867,8 @@ async function flushPendingIceCandidates(call) { async function closeMedia(call) { const pc = call?.pc || null; + const beforeSnapshot = buildMediaSnapshot(call); + recordCallTimeline(call, 'media_close_begin', summarizeMediaSnapshot(beforeSnapshot)); try { const senders = pc?.getSenders?.() || []; senders.forEach((sender) => { @@ -822,6 +883,16 @@ async function closeMedia(call) { try { tr?.stop?.(); } catch {} }); } catch {} + try { + if (pc) { + pc.ontrack = null; + pc.onicecandidate = null; + pc.onconnectionstatechange = null; + pc.oniceconnectionstatechange = null; + pc.onsignalingstatechange = null; + pc.onicegatheringstatechange = null; + } + } catch {} try { call.localStream?.getTracks?.()?.forEach((track) => { try { track.enabled = false; } catch {} @@ -830,8 +901,15 @@ async function closeMedia(call) { } catch {} try { if (call.remoteAudio) { + const remoteTracks = call.remoteAudio.srcObject?.getTracks?.() || []; + remoteTracks.forEach((track) => { + try { track.enabled = false; } catch {} + try { track.stop(); } catch {} + }); try { call.remoteAudio.pause?.(); } catch {} try { call.remoteAudio.srcObject = null; } catch {} + try { call.remoteAudio.removeAttribute?.('src'); } catch {} + try { call.remoteAudio.load?.(); } catch {} call.remoteAudio = null; } } catch {} @@ -842,6 +920,14 @@ async function closeMedia(call) { call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; call.pendingRemoteIceCandidates = []; + const afterSnapshot = buildMediaSnapshot(call); + recordCallTimeline(call, 'media_close_end', summarizeMediaSnapshot(afterSnapshot)); + await emitDebug( + call, + 'info', + 'media_closed', + `before=${summarizeMediaSnapshot(beforeSnapshot)} | after=${summarizeMediaSnapshot(afterSnapshot)}`, + ); } function stopReconnectFlow(call) { diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index 4fc20aff..c9102eb5 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -6,29 +6,116 @@ import { subscribeCallState, } from './call-service.js'; -let shellEl = null; +const MINIMIZED_BAR_HEIGHT_PX = 44; + +let rootEl = null; +let overlayEl = null; let panelEl = null; +let headerEl = null; let titleEl = null; let statusEl = null; let muteBtn = null; let acceptBtn = null; let declineBtn = null; let hangupBtn = null; +let minimizeBtn = null; +let barEl = null; +let barTextEl = null; let unbind = null; +let tickerId = 0; +let currentSnapshot = null; +let minimized = false; + +function resolveAppShell() { + return document.querySelector('.app-shell') || document.body; +} + +function pad2(value) { + return String(Math.max(0, Number(value) || 0)).padStart(2, '0'); +} + +function formatElapsedMs(ms) { + const totalSec = Math.max(0, Math.floor(Number(ms || 0) / 1000)); + const hours = Math.floor(totalSec / 3600); + const minutes = Math.floor((totalSec % 3600) / 60); + const seconds = totalSec % 60; + if (hours > 0) return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`; + return `${pad2(minutes)}:${pad2(seconds)}`; +} + +function getSnapshotElapsedMs(snapshot) { + const baseMs = Number(snapshot?.connectedAtMs || snapshot?.startedAtMs || 0); + if (!baseMs || !Number.isFinite(baseMs)) return 0; + return Math.max(0, Date.now() - baseMs); +} + +function buildBarText(snapshot) { + const peer = String(snapshot?.peerLogin || '').trim() || 'пользователь'; + const elapsed = formatElapsedMs(getSnapshotElapsedMs(snapshot)); + return `Звонок с ${peer} · ${elapsed}`; +} + +function setMinimized(nextValue) { + minimized = Boolean(nextValue); + render(); +} + +function ensureTicker() { + const shouldTick = Boolean(currentSnapshot); + if (shouldTick && !tickerId) { + tickerId = window.setInterval(() => { + if (!currentSnapshot) return; + render(); + }, 1000); + } + if (!shouldTick && tickerId) { + window.clearInterval(tickerId); + tickerId = 0; + } +} function ensureUi() { - if (shellEl) return; + if (rootEl) return; - shellEl = document.createElement('section'); - shellEl.className = 'call-overlay'; - shellEl.hidden = true; + rootEl = document.createElement('section'); + rootEl.className = 'call-ui-root'; + rootEl.hidden = true; + + barEl = document.createElement('button'); + barEl.type = 'button'; + barEl.className = 'call-minimized-bar'; + barEl.hidden = true; + barEl.addEventListener('click', () => { + if (!currentSnapshot) return; + setMinimized(false); + }); + + barTextEl = document.createElement('span'); + barTextEl.className = 'call-minimized-bar-text'; + barEl.append(barTextEl); + + overlayEl = document.createElement('div'); + overlayEl.className = 'call-overlay'; + overlayEl.hidden = true; panelEl = document.createElement('div'); panelEl.className = 'call-overlay-panel'; + headerEl = document.createElement('div'); + headerEl.className = 'call-overlay-head'; + titleEl = document.createElement('h2'); titleEl.className = 'call-overlay-title'; + minimizeBtn = document.createElement('button'); + minimizeBtn.type = 'button'; + minimizeBtn.className = 'secondary-btn call-overlay-minimize-btn'; + minimizeBtn.textContent = 'Свернуть'; + minimizeBtn.addEventListener('click', () => { + if (!currentSnapshot) return; + setMinimized(true); + }); + statusEl = document.createElement('div'); statusEl.className = 'call-overlay-status'; @@ -69,20 +156,17 @@ function ensureUi() { }); controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn); - panelEl.append(titleEl, statusEl, controls); - shellEl.append(panelEl); - document.body.append(shellEl); + headerEl.append(titleEl, minimizeBtn); + panelEl.append(headerEl, statusEl, controls); + overlayEl.append(panelEl); + rootEl.append(barEl, overlayEl); + resolveAppShell().append(rootEl); } -function applyCallState(snapshot) { - ensureUi(); +function applyExpandedState(snapshot) { + overlayEl.hidden = false; + barEl.hidden = true; - if (!snapshot) { - shellEl.hidden = true; - return; - } - - shellEl.hidden = false; titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`; statusEl.textContent = snapshot.statusText || ''; @@ -95,10 +179,62 @@ function applyCallState(snapshot) { acceptBtn.hidden = !snapshot.canAnswer; declineBtn.hidden = !snapshot.canDecline; - hangupBtn.hidden = !snapshot.canHangup; } +function applyMinimizedState(snapshot) { + overlayEl.hidden = true; + barEl.hidden = false; + barTextEl.textContent = buildBarText(snapshot); +} + +function syncShellState() { + const appShell = resolveAppShell(); + if (!appShell) return; + if (currentSnapshot && minimized) { + appShell.classList.add('has-minimized-call'); + const measuredHeight = Math.max( + MINIMIZED_BAR_HEIGHT_PX, + Math.ceil(Number(barEl?.offsetHeight || 0)), + ); + appShell.style.setProperty('--call-minimized-bar-height', `${measuredHeight}px`); + } else { + appShell.classList.remove('has-minimized-call'); + appShell.style.removeProperty('--call-minimized-bar-height'); + } +} + +function render() { + ensureUi(); + rootEl.hidden = !currentSnapshot; + ensureTicker(); + + if (!currentSnapshot) { + syncShellState(); + overlayEl.hidden = true; + barEl.hidden = true; + return; + } + + if (minimized) { + applyMinimizedState(currentSnapshot); + syncShellState(); + return; + } + + applyExpandedState(currentSnapshot); + syncShellState(); +} + +function applyCallState(snapshot) { + ensureUi(); + currentSnapshot = snapshot || null; + if (!currentSnapshot) { + minimized = false; + } + render(); +} + export function initCallUiOverlay() { ensureUi(); if (unbind) { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 33b33a52..7f65c562 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -1733,16 +1733,57 @@ display: none; } -.call-overlay { - position: fixed; +.call-ui-root[hidden] { + display: none; +} + +.call-ui-root { + position: absolute; inset: 0; z-index: 80; + pointer-events: none; +} + +.call-minimized-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + min-height: 44px; + padding: calc(8px + env(safe-area-inset-top)) 14px 8px; + border: 0; + border-bottom: 1px solid rgba(159, 255, 196, 0.28); + background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98)); + box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28); + color: #f3fff8; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + text-align: left; + pointer-events: auto; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.call-minimized-bar-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.call-overlay { + position: absolute; + inset: 0; display: flex; align-items: flex-end; justify-content: center; padding: 16px; background: rgba(6, 10, 16, 0.55); backdrop-filter: blur(4px); + pointer-events: auto; } .call-overlay-panel { @@ -1756,12 +1797,23 @@ gap: 10px; } +.call-overlay-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + .call-overlay-title { margin: 0; font-size: 20px; color: #eff4ff; } +.call-overlay-minimize-btn { + flex: 0 0 auto; +} + .call-overlay-status { color: #c8d7f6; font-size: 14px; diff --git a/shine-UI/styles/layout.css b/shine-UI/styles/layout.css index 4b44cc08..00d0ddef 100644 --- a/shine-UI/styles/layout.css +++ b/shine-UI/styles/layout.css @@ -24,6 +24,7 @@ body::before { width: min(100vw, 430px); height: 100dvh; position: relative; + --call-minimized-bar-height: 0px; background: transparent; border-left: 1px solid rgba(211, 170, 86, 0.2); border-right: 1px solid rgba(211, 170, 86, 0.2); @@ -39,7 +40,7 @@ body::before { .screen-content { position: absolute; - top: 0; + top: var(--call-minimized-bar-height, 0px); left: 0; right: 0; bottom: 74px; @@ -52,6 +53,10 @@ body::before { padding-bottom: calc(24px + env(safe-area-inset-bottom)); } +.app-shell.has-minimized-call .screen-content { + padding-top: 12px; +} + .screen-content.network-scroll-lock { overflow: hidden; padding: 0;