import { acceptIncomingCall, declineIncomingCall, hangupActiveCall, setCameraEnabled, setMicMuted, subscribeCallState, toggleSpeakerMode, } from './call-service.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 remoteVideoPlaceholderEl = null; let localPreviewWrapEl = null; let localVideoEl = 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; 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 stopEvent(event) { event.preventDefault(); event.stopPropagation(); } 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 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('h2'); 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; remoteVideoPlaceholderEl = document.createElement('div'); remoteVideoPlaceholderEl.className = 'call-video-placeholder'; remoteVideoPlaceholderEl.textContent = 'Собеседник пока без видео'; localPreviewWrapEl = document.createElement('div'); localPreviewWrapEl.className = 'call-local-preview'; localVideoEl = document.createElement('video'); localVideoEl.className = 'call-local-video'; localVideoEl.autoplay = true; localVideoEl.playsInline = true; localVideoEl.muted = true; localPreviewWrapEl.append(localVideoEl); mediaStageEl.append(remoteVideoEl, remoteVideoPlaceholderEl, 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(headerEl, statusEl, mediaStageEl, incomingActionsEl, activeActionsEl); overlayEl.append(panelEl); rootEl.append(barEl, overlayEl); resolveAppShell().append(rootEl); } function applyExpandedState(snapshot) { overlayEl.hidden = false; barEl.hidden = true; titleEl.textContent = `Звонок: ${snapshot.peerLogin || 'пользователь'}`; statusEl.textContent = snapshot.statusText || ''; const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline); const muted = Boolean(snapshot.muted); updateMuteButtons(muted); updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); updateCameraButtons(Boolean(snapshot.cameraEnabled)); 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 = incomingStage; 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; remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo; localPreviewWrapEl.hidden = !snapshot.hasLocalVideo; } 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); }