SHA256
616 lines
23 KiB
JavaScript
616 lines
23 KiB
JavaScript
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 '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 12 0" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
|
||
}
|
||
if (name === 'mic-off') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3.5" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6 11.5a6 6 0 0 0 7.2 5.88" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M12 17.5v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M8.5 20.5h7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||
}
|
||
if (name === 'hangup') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
|
||
}
|
||
if (name === 'minimize') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 6v12M7.5 10.5 12 6l4.5 4.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||
}
|
||
if (name === 'speaker-on') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 14h4l5 4V6L8 10H4z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 9.5a4.5 4.5 0 0 1 0 5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M18.8 7a8 8 0 0 1 0 10" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||
}
|
||
if (name === 'speaker-off') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10H4v4h4l5 4V6z" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round"/><path d="M16 8l4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/><path d="M20 8l-4 8" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||
}
|
||
if (name === 'camera-on') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
|
||
}
|
||
if (name === 'camera-off') {
|
||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
|
||
}
|
||
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);
|
||
}
|