SHA256
UI: стадии и звук звонка
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user