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

372 lines
13 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,
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 incomingActionsEl = null;
let activeActionsEl = null;
let speakerBtn = 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 barSpeakerBtn = 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>';
}
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>';
}
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);
}
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();
}
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);
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, 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';
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);
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, hangupBtn);
headerEl.append(titleEl, minimizeBtn);
panelEl.append(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 = `Звонок: ${snapshot.peerLogin || 'пользователь'}`;
statusEl.textContent = snapshot.statusText || '';
const incomingStage = Boolean(snapshot.canAnswer || snapshot.canDecline);
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
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;
acceptBtn.hidden = !incomingStage || !snapshot.canAnswer;
declineBtn.hidden = !incomingStage || !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup;
}
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));
barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
barMuteBtn.hidden = incomingStage || !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute;
const canEnd = !incomingStage && snapshot.canHangup;
barHangupBtn.hidden = !canEnd;
barHangupBtn.disabled = !canEnd;
setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку');
barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !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);
}