diff --git a/VERSION.properties b/VERSION.properties
index 167f3309..58084888 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.349
+client.version=1.2.350
server.version=1.2.319
diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js
index 64ef0549..c1865340 100644
--- a/shine-UI/js/services/call-service.js
+++ b/shine-UI/js/services/call-service.js
@@ -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();
diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js
index a392e4c7..f544c50b 100644
--- a/shine-UI/js/services/call-ui-service.js
+++ b/shine-UI/js/services/call-ui-service.js
@@ -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 '';
}
+ if (name === 'speaker-on') {
+ return '';
+ }
+ if (name === 'speaker-off') {
+ return '';
+ }
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;
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index ea060e6a..419ed621 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -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);