UI: стадии и звук звонка

This commit is contained in:
AidarKC
2026-07-24 19:12:18 +04:00
parent 69faf55b2b
commit f31cdb413b
4 changed files with 109 additions and 7 deletions
+59
View File
@@ -37,6 +37,25 @@ function nowMs() {
return Date.now();
}
function isMobileBrowser() {
const uaDataMobile = navigator?.userAgentData?.mobile;
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
const ua = String(navigator?.userAgent || '');
return /Android|iPhone|iPad|iPod|Mobile/i.test(ua);
}
function canRouteAudioOutputInBrowser() {
const mediaProto = globalThis?.HTMLMediaElement?.prototype;
return Boolean(
window.isSecureContext
&& isMobileBrowser()
&& mediaProto
&& typeof mediaProto.setSinkId === 'function'
&& navigator?.mediaDevices
&& typeof navigator.mediaDevices.selectAudioOutput === 'function'
);
}
function sanitizeTimelineText(value) {
return String(value || '')
.replace(/\s+/g, ' ')
@@ -548,6 +567,12 @@ function getCallStateSnapshot() {
if (!call) return null;
if (call.localUiDismissed) return null;
const callPhase = String(call.phase || '').trim();
const canToggleSpeaker = Boolean(
callPhase !== 'incoming'
&& callPhase !== 'ended'
&& canRouteAudioOutputInBrowser()
&& call.remoteAudio
);
return {
callId: call.callId,
peerLogin: call.peerLogin || '',
@@ -557,6 +582,8 @@ function getCallStateSnapshot() {
startedAtMs: Number(call.startedAtMs || 0),
connectedAtMs: Number(call.connectedAtMs || 0),
muted: Boolean(call.muted),
speakerEnabled: Boolean(call.speakerEnabled),
canToggleSpeaker,
canAnswer: callPhase === 'incoming',
canDecline: callPhase === 'incoming',
canHangup: callPhase !== 'ended' && callPhase !== 'incoming',
@@ -1400,8 +1427,10 @@ async function ensurePeerConnection(call) {
recordCallTimeline(call, 'remote_track_received', `streams=${evt?.streams?.length || 0}`);
const audio = new Audio();
audio.autoplay = true;
audio.playsInline = true;
audio.srcObject = evt.streams[0];
call.remoteAudio = audio;
notifyCallState();
};
call.pc = pc;
@@ -1602,6 +1631,36 @@ export async function toggleMicMuted() {
await setMicMuted(!call.muted);
}
export async function toggleSpeakerMode() {
const call = getActiveCall();
if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false;
const audio = call.remoteAudio;
if (Boolean(call.speakerEnabled)) {
try {
await audio.setSinkId('');
call.speakerEnabled = false;
notifyCallState();
return true;
} catch {
return false;
}
}
try {
const options = call.speakerSinkId ? { deviceId: call.speakerSinkId } : undefined;
const selected = await navigator.mediaDevices.selectAudioOutput(options);
const deviceId = String(selected?.deviceId || '').trim();
if (!deviceId) return false;
await audio.setSinkId(deviceId);
call.speakerSinkId = deviceId;
call.speakerEnabled = true;
notifyCallState();
return true;
} catch {
return false;
}
}
export async function startDebugConnectionAsResponder({ runId, callId, peerLogin, peerSessionId }) {
const cleanCallId = String(callId || '').trim();
const cleanPeerLogin = String(peerLogin || '').trim();
+45 -6
View File
@@ -4,6 +4,7 @@ import {
hangupActiveCall,
setMicMuted,
subscribeCallState,
toggleSpeakerMode,
} from './call-service.js';
const MINIMIZED_BAR_HEIGHT_PX = 44;
@@ -14,6 +15,7 @@ let panelEl = null;
let headerEl = null;
let titleEl = null;
let statusEl = null;
let speakerBtn = null;
let muteBtn = null;
let acceptBtn = null;
let declineBtn = null;
@@ -22,6 +24,7 @@ let minimizeBtn = null;
let barEl = null;
let barTextEl = null;
let barActionsEl = null;
let barSpeakerBtn = null;
let barMuteBtn = null;
let barHangupBtn = null;
let unbind = null;
@@ -76,6 +79,12 @@ function getIconSvg(name) {
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 '';
}
@@ -95,6 +104,13 @@ function updateMuteButtons(muted) {
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';
@@ -111,6 +127,11 @@ async function handleEndCallClick(event) {
await hangupActiveCall();
}
async function handleSpeakerClick(event) {
stopEvent(event);
await toggleSpeakerMode();
}
function setMinimized(nextValue) {
minimized = Boolean(nextValue);
render();
@@ -152,6 +173,11 @@ function ensureUi() {
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';
@@ -162,7 +188,7 @@ function ensureUi() {
barHangupBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--danger';
barHangupBtn.addEventListener('click', handleEndCallClick);
barActionsEl.append(barMuteBtn, barHangupBtn);
barActionsEl.append(barSpeakerBtn, barMuteBtn, barHangupBtn);
barEl.append(barTextEl, barActionsEl);
overlayEl = document.createElement('div');
@@ -194,6 +220,11 @@ function ensureUi() {
const controls = document.createElement('div');
controls.className = 'call-overlay-controls';
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';
@@ -209,8 +240,8 @@ function ensureUi() {
declineBtn = document.createElement('button');
declineBtn.type = 'button';
declineBtn.className = 'call-icon-btn call-icon-btn--danger';
setButtonIcon(declineBtn, 'hangup', 'Отклонить звонок');
declineBtn.className = 'destructive-btn call-decline-btn';
declineBtn.textContent = 'Отклонить';
declineBtn.addEventListener('click', async () => {
await declineIncomingCall();
});
@@ -221,7 +252,7 @@ function ensureUi() {
setButtonIcon(hangupBtn, 'hangup', 'Положить трубку');
hangupBtn.addEventListener('click', handleEndCallClick);
controls.append(muteBtn, acceptBtn, declineBtn, hangupBtn);
controls.append(speakerBtn, muteBtn, acceptBtn, declineBtn, hangupBtn);
headerEl.append(titleEl, minimizeBtn);
panelEl.append(headerEl, statusEl, controls);
overlayEl.append(panelEl);
@@ -236,15 +267,20 @@ function applyExpandedState(snapshot) {
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));
muteBtn.hidden = !snapshot.canMute;
speakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker;
speakerBtn.disabled = !snapshot.canToggleSpeaker;
muteBtn.hidden = incomingStage || !snapshot.canMute;
muteBtn.disabled = !snapshot.canMute;
acceptBtn.hidden = !snapshot.canAnswer;
declineBtn.hidden = !snapshot.canDecline;
hangupBtn.hidden = !snapshot.canHangup;
hangupBtn.hidden = incomingStage || !snapshot.canHangup;
}
function applyMinimizedState(snapshot) {
@@ -253,6 +289,9 @@ function applyMinimizedState(snapshot) {
barTextEl.textContent = buildBarText(snapshot);
const muted = Boolean(snapshot.muted);
updateMuteButtons(muted);
updateSpeakerButtons(Boolean(snapshot.speakerEnabled));
barSpeakerBtn.hidden = !snapshot.canToggleSpeaker;
barSpeakerBtn.disabled = !snapshot.canToggleSpeaker;
barMuteBtn.hidden = !snapshot.canMute;
barMuteBtn.disabled = !snapshot.canMute;
const canEnd = snapshot.canHangup || snapshot.canDecline;
+4
View File
@@ -1903,6 +1903,10 @@
align-items: center;
}
.call-decline-btn {
min-width: 120px;
}
.call-accept-btn {
border: 1px solid rgba(122, 230, 166, 0.64);
border-radius: var(--radius-sm);