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: 'xl',
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);
}