import { acceptIncomingCall, declineIncomingCall, hangupActiveCall, setCameraEnabled, setMicMuted, subscribeCallState, toggleSpeakerMode, } from './call-service.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { loadProfileSnapshot } from './user-profile-params.js'; 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 mediaStageEl = null; let remoteVideoEl = null; let localPreviewWrapEl = null; let localVideoEl = null; let heroEl = null; let avatarSlotEl = null; let peerLoginEl = null; let kindEl = null; let topStatusEl = null; let incomingActionsEl = null; let activeActionsEl = null; let speakerBtn = null; let muteBtn = null; let cameraBtn = null; let acceptBtn = null; let declineBtn = null; let hangupBtn = null; let minimizeBtn = null; let barEl = null; let barTextEl = null; let barActionsEl = null; let barSpeakerBtn = null; let barMuteBtn = null; let barCameraBtn = null; let barHangupBtn = null; let unbind = null; let tickerId = 0; let currentSnapshot = null; let minimized = false; let activeAvatarLogin = ''; let activeAvatarLoadToken = 0; let localPreviewDragPointerId = null; let localPreviewDragRect = null; let localPreviewDragOffsetX = 0; let localPreviewDragOffsetY = 0; let localPreviewPosition = null; const avatarSnapshotCache = new Map(); const avatarPendingByLogin = new Map(); 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)); const kind = String(snapshot?.titleText || 'Звонок').trim() || 'Звонок'; return `${kind} с ${peer} · ${elapsed}`; } async function loadAvatarSnapshot(login) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) return null; const key = cleanLogin.toLowerCase(); if (avatarSnapshotCache.has(key)) return avatarSnapshotCache.get(key); if (avatarPendingByLogin.has(key)) return avatarPendingByLogin.get(key); const pending = loadProfileSnapshot(cleanLogin) .then((snapshot) => { avatarSnapshotCache.set(key, snapshot || null); avatarPendingByLogin.delete(key); return snapshot || null; }) .catch(() => { avatarSnapshotCache.set(key, null); avatarPendingByLogin.delete(key); return null; }); avatarPendingByLogin.set(key, pending); return pending; } function renderPeerAvatar(login, avatar = null) { const hasAvatar = Boolean(String(avatar?.ar || '').trim()); const avatarEl = renderUserAvatar({ login: String(login || '').trim() || 'unknown', avatar, size: 'large', className: 'call-peer-avatar', title: login ? `Профиль ${login}` : 'Профиль собеседника', }); if (!hasAvatar) { avatarEl.classList.add('call-peer-avatar--empty'); const fallbackEl = avatarEl.querySelector('.avatar-fallback'); if (fallbackEl) fallbackEl.textContent = ''; } return avatarEl; } function updatePeerAvatar(login) { if (!avatarSlotEl) return; const cleanLogin = String(login || '').trim(); if (activeAvatarLogin === cleanLogin && avatarSlotEl.childElementCount > 0) return; activeAvatarLogin = cleanLogin; const loadToken = ++activeAvatarLoadToken; avatarSlotEl.innerHTML = ''; avatarSlotEl.append(renderPeerAvatar(cleanLogin)); if (!cleanLogin) return; void loadAvatarSnapshot(cleanLogin).then((snapshot) => { if (!avatarSlotEl || loadToken !== activeAvatarLoadToken || activeAvatarLogin !== cleanLogin) return; const avatar = snapshot?.avatar?.txId ? { ar: String(snapshot.avatar.txId || '').trim(), sha256Hex: String(snapshot.avatar.sha256Hex || '').trim().toLowerCase(), } : null; const upgraded = renderPeerAvatar(cleanLogin, avatar); avatarSlotEl.replaceChildren(upgraded); }); } function stopEvent(event) { event.preventDefault(); event.stopPropagation(); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function getIconSvg(name) { if (name === 'mic-on') { return ''; } if (name === 'mic-off') { return ''; } if (name === 'hangup') { return ''; } if (name === 'minimize') { return ''; } if (name === 'speaker-on') { return ''; } if (name === 'speaker-off') { return ''; } if (name === 'camera-on') { return ''; } if (name === 'camera-off') { return ''; } return ''; } function setButtonIcon(button, iconName, label) { if (!button) return; button.innerHTML = getIconSvg(iconName); button.setAttribute('aria-label', label); button.title = label; } function updateMuteButtons(muted) { const iconName = muted ? 'mic-off' : 'mic-on'; const label = muted ? 'Включить микрофон' : 'Выключить микрофон'; setButtonIcon(muteBtn, iconName, label); setButtonIcon(barMuteBtn, iconName, label); if (muteBtn) muteBtn.dataset.muted = muted ? '1' : '0'; if (barMuteBtn) barMuteBtn.dataset.muted = muted ? '1' : '0'; } function updateSpeakerButtons(enabled) { const iconName = enabled ? 'speaker-on' : 'speaker-off'; const label = enabled ? 'Выключить громкую связь' : 'Включить громкую связь'; setButtonIcon(speakerBtn, iconName, label); setButtonIcon(barSpeakerBtn, iconName, label); } function updateCameraButtons(enabled) { const iconName = enabled ? 'camera-on' : 'camera-off'; const label = enabled ? 'Выключить камеру' : 'Включить камеру'; setButtonIcon(cameraBtn, iconName, label); setButtonIcon(barCameraBtn, iconName, label); if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0'; if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0'; } async function handleMuteClick(event) { stopEvent(event); const nowMuted = event.currentTarget?.dataset?.muted === '1'; await setMicMuted(!nowMuted); } async function handleEndCallClick(event) { stopEvent(event); if (!currentSnapshot) return; if (currentSnapshot.canDecline && !currentSnapshot.canHangup) { await declineIncomingCall(); return; } await hangupActiveCall(); } async function handleSpeakerClick(event) { stopEvent(event); await toggleSpeakerMode(); } async function handleCameraClick(event) { stopEvent(event); const enabled = event.currentTarget?.dataset?.enabled === '1'; try { await setCameraEnabled(!enabled); } catch {} } 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 applyLocalPreviewPosition() { if (!localPreviewWrapEl) return; if (!localPreviewPosition) { localPreviewWrapEl.style.left = ''; localPreviewWrapEl.style.top = ''; localPreviewWrapEl.style.right = ''; localPreviewWrapEl.style.bottom = ''; return; } localPreviewWrapEl.style.left = `${localPreviewPosition.leftPx}px`; localPreviewWrapEl.style.top = `${localPreviewPosition.topPx}px`; localPreviewWrapEl.style.right = 'auto'; localPreviewWrapEl.style.bottom = 'auto'; } function updateLocalPreviewPositionFromPointer(clientX, clientY) { if (!mediaStageEl || !localPreviewWrapEl || !localPreviewDragRect) return; const stageRect = mediaStageEl.getBoundingClientRect(); const width = localPreviewDragRect.width; const height = localPreviewDragRect.height; const nextLeft = clamp(clientX - stageRect.left - localPreviewDragOffsetX, 8, Math.max(8, stageRect.width - width - 8)); const nextTop = clamp(clientY - stageRect.top - localPreviewDragOffsetY, 8, Math.max(8, stageRect.height - height - 8)); localPreviewPosition = { leftPx: Math.round(nextLeft), topPx: Math.round(nextTop), }; applyLocalPreviewPosition(); } function handleLocalPreviewPointerDown(event) { if (!localPreviewWrapEl || event.pointerType === 'mouse' && event.button !== 0) return; localPreviewDragPointerId = event.pointerId; localPreviewDragRect = localPreviewWrapEl.getBoundingClientRect(); localPreviewDragOffsetX = event.clientX - localPreviewDragRect.left; localPreviewDragOffsetY = event.clientY - localPreviewDragRect.top; try { localPreviewWrapEl.setPointerCapture(event.pointerId); } catch {} stopEvent(event); } function handleLocalPreviewPointerMove(event) { if (localPreviewDragPointerId !== event.pointerId) return; updateLocalPreviewPositionFromPointer(event.clientX, event.clientY); stopEvent(event); } function stopLocalPreviewDrag(event = null) { if (event && localPreviewDragPointerId !== event.pointerId) return; if (event && localPreviewWrapEl) { try { localPreviewWrapEl.releasePointerCapture(event.pointerId); } catch {} } localPreviewDragPointerId = null; localPreviewDragRect = null; } function ensureUi() { if (rootEl) return; rootEl = document.createElement('section'); rootEl.className = 'call-ui-root'; rootEl.hidden = true; barEl = document.createElement('div'); barEl.className = 'call-minimized-bar'; barEl.hidden = true; barTextEl = document.createElement('button'); barTextEl.type = 'button'; barTextEl.className = 'call-minimized-bar-text'; barTextEl.addEventListener('click', () => { if (!currentSnapshot) return; setMinimized(false); }); barActionsEl = document.createElement('div'); barActionsEl.className = 'call-minimized-bar-actions'; barSpeakerBtn = document.createElement('button'); barSpeakerBtn.type = 'button'; barSpeakerBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; barSpeakerBtn.addEventListener('click', handleSpeakerClick); barMuteBtn = document.createElement('button'); barMuteBtn.type = 'button'; barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; barMuteBtn.addEventListener('click', handleMuteClick); barCameraBtn = document.createElement('button'); barCameraBtn.type = 'button'; barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; barCameraBtn.addEventListener('click', handleCameraClick); barHangupBtn = document.createElement('button'); barHangupBtn.type = 'button'; barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger'; barHangupBtn.addEventListener('click', handleEndCallClick); barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn); barEl.append(barTextEl, barActionsEl); 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('div'); titleEl.className = 'call-overlay-title'; minimizeBtn = document.createElement('button'); minimizeBtn.type = 'button'; minimizeBtn.className = 'call-icon-btn call-icon-btn--secondary call-overlay-minimize-btn'; setButtonIcon(minimizeBtn, 'minimize', 'Свернуть звонок'); minimizeBtn.addEventListener('click', (event) => { stopEvent(event); if (!currentSnapshot) return; setMinimized(true); }); statusEl = document.createElement('div'); statusEl.className = 'call-overlay-status'; mediaStageEl = document.createElement('div'); mediaStageEl.className = 'call-video-stage'; remoteVideoEl = document.createElement('video'); remoteVideoEl.className = 'call-remote-video'; remoteVideoEl.autoplay = true; remoteVideoEl.playsInline = true; remoteVideoEl.muted = true; localPreviewWrapEl = document.createElement('div'); localPreviewWrapEl.className = 'call-local-preview'; localPreviewWrapEl.addEventListener('pointerdown', handleLocalPreviewPointerDown); localPreviewWrapEl.addEventListener('pointermove', handleLocalPreviewPointerMove); localPreviewWrapEl.addEventListener('pointerup', stopLocalPreviewDrag); localPreviewWrapEl.addEventListener('pointercancel', stopLocalPreviewDrag); localVideoEl = document.createElement('video'); localVideoEl.className = 'call-local-video'; localVideoEl.autoplay = true; localVideoEl.playsInline = true; localVideoEl.muted = true; heroEl = document.createElement('div'); heroEl.className = 'call-hero'; avatarSlotEl = document.createElement('div'); avatarSlotEl.className = 'call-peer-avatar-slot'; peerLoginEl = document.createElement('div'); peerLoginEl.className = 'call-peer-login'; kindEl = document.createElement('div'); kindEl.className = 'call-kind'; topStatusEl = document.createElement('div'); topStatusEl.className = 'call-hero-status'; heroEl.append(avatarSlotEl, peerLoginEl, kindEl, topStatusEl); localPreviewWrapEl.append(localVideoEl); mediaStageEl.append(remoteVideoEl, heroEl, localPreviewWrapEl); incomingActionsEl = document.createElement('div'); incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming'; activeActionsEl = document.createElement('div'); activeActionsEl.className = 'call-overlay-controls call-overlay-controls--active'; speakerBtn = document.createElement('button'); speakerBtn.type = 'button'; speakerBtn.className = 'call-icon-btn call-icon-btn--secondary'; speakerBtn.addEventListener('click', handleSpeakerClick); muteBtn = document.createElement('button'); muteBtn.type = 'button'; muteBtn.className = 'call-icon-btn call-icon-btn--secondary'; muteBtn.addEventListener('click', handleMuteClick); cameraBtn = document.createElement('button'); cameraBtn.type = 'button'; cameraBtn.className = 'call-icon-btn call-icon-btn--secondary'; cameraBtn.addEventListener('click', handleCameraClick); acceptBtn = document.createElement('button'); acceptBtn.type = 'button'; acceptBtn.className = 'call-accept-btn'; acceptBtn.textContent = 'Ответить'; acceptBtn.addEventListener('click', async () => { await acceptIncomingCall(); }); declineBtn = document.createElement('button'); declineBtn.type = 'button'; declineBtn.className = 'destructive-btn call-decline-btn'; declineBtn.textContent = 'Отклонить'; declineBtn.addEventListener('click', async () => { await declineIncomingCall(); }); hangupBtn = document.createElement('button'); hangupBtn.type = 'button'; hangupBtn.className = 'call-icon-btn call-icon-btn--danger'; setButtonIcon(hangupBtn, 'hangup', 'Положить трубку'); hangupBtn.addEventListener('click', handleEndCallClick); incomingActionsEl.append(acceptBtn, declineBtn); activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn); headerEl.append(titleEl, minimizeBtn); panelEl.append(mediaStageEl, headerEl, statusEl, incomingActionsEl, activeActionsEl); overlayEl.append(panelEl); rootEl.append(barEl, overlayEl); resolveAppShell().append(rootEl); } function applyExpandedState(snapshot) { overlayEl.hidden = false; barEl.hidden = true; titleEl.textContent = ''; statusEl.textContent = ''; peerLoginEl.textContent = snapshot.peerLogin || 'пользователь'; kindEl.textContent = snapshot.titleText || 'Звонок'; topStatusEl.textContent = snapshot.statusText || ''; updatePeerAvatar(snapshot.peerLogin || ''); const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline); const muted = Boolean(snapshot.muted); updateMuteButtons(muted); updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); updateCameraButtons(Boolean(snapshot.cameraEnabled)); panelEl.classList.toggle('call-overlay-panel--remote-video', Boolean(snapshot.hasRemoteVideo)); mediaStageEl.classList.toggle('call-video-stage--remote-video', Boolean(snapshot.hasRemoteVideo)); heroEl.hidden = Boolean(snapshot.hasRemoteVideo); minimizeBtn.hidden = incomingStage; minimizeBtn.disabled = incomingStage; incomingActionsEl.hidden = !incomingStage; activeActionsEl.hidden = incomingStage; speakerBtn.hidden = !snapshot.canToggleSpeaker; speakerBtn.disabled = !snapshot.canToggleSpeaker; muteBtn.hidden = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute; cameraBtn.hidden = !snapshot.canToggleCamera; cameraBtn.disabled = !snapshot.canToggleCamera; acceptBtn.hidden = !incomingStage || !snapshot.canAnswer; declineBtn.hidden = !incomingStage || !snapshot.canDecline; hangupBtn.hidden = !snapshot.canHangup; mediaStageEl.hidden = false; const remoteStream = snapshot.remoteMediaStream || null; const localStream = snapshot.localPreviewStream || null; const remoteVideoSource = snapshot.hasRemoteVideo ? remoteStream : null; if (remoteVideoEl.srcObject !== remoteVideoSource) remoteVideoEl.srcObject = remoteVideoSource; if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream; remoteVideoEl.hidden = !snapshot.hasRemoteVideo; localPreviewWrapEl.hidden = !snapshot.hasLocalVideo; if (!snapshot.hasLocalVideo) { localPreviewPosition = null; applyLocalPreviewPosition(); } } function applyMinimizedState(snapshot) { overlayEl.hidden = true; barEl.hidden = false; barTextEl.textContent = buildBarText(snapshot); const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline); const muted = Boolean(snapshot.muted); updateMuteButtons(muted); updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); updateCameraButtons(Boolean(snapshot.cameraEnabled)); barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker; barSpeakerBtn.disabled = !snapshot.canToggleSpeaker; barMuteBtn.hidden = incomingStage || !snapshot.canMute; barMuteBtn.disabled = !snapshot.canMute; barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera; barCameraBtn.disabled = !snapshot.canToggleCamera; const canEnd = !incomingStage && snapshot.canHangup; barHangupBtn.hidden = !canEnd; barHangupBtn.disabled = !canEnd; setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку'); barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd); } 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) { unbind(); unbind = null; } unbind = subscribeCallState(applyCallState); }