Files
SHiNE-server/shine-UI/js/services/call-ui-service.js
T

319 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
acceptIncomingCall,
declineIncomingCall,
hangupActiveCall,
setMicMuted,
subscribeCallState,
} 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 muteBtn = null;
let acceptBtn = null;
let declineBtn = null;
let hangupBtn = null;
let minimizeBtn = null;
let barEl = null;
let barTextEl = null;
let barActionsEl = null;
let barMuteBtn = 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 '<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>';
}
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';
}
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();
}
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';
barMuteBtn = document.createElement('button');
barMuteBtn.type = 'button';
barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary';
barMuteBtn.addEventListener('click', handleMuteClick);
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(barMuteBtn, 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';
const controls = document.createElement('div');
controls.className = 'call-overlay-controls';
muteBtn = document.createElement('button');
muteBtn.type = 'button';
muteBtn.className = 'call-icon-btn call-icon-btn--secondary';
muteBtn.addEventListener('click', handleMuteClick);
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 = 'call-icon-btn call-icon-btn--danger';
setButtonIcon(declineBtn, 'hangup', 'Отклонить звонок');
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);
controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn);
headerEl.append(titleEl, minimizeBtn);
panelEl.append(headerEl, statusEl, controls);
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 muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
muteBtn.hidden = !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute;
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);
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
barMuteBtn.hidden = !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute;
const canEnd = snapshot.canHangup || snapshot.canDecline;
barHangupBtn.hidden = !canEnd;
barHangupBtn.disabled = !canEnd;
setButtonIcon(barHangupBtn, 'hangup', snapshot.canDecline && !snapshot.canHangup ? 'Отклонить звонок' : 'Положить трубку');
}
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);
}