From f31cdb413b91ad9e04c3b74fcab4db4d420cd01e5a41800d5ac28a246d9b8c97 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 24 Jul 2026 19:12:18 +0400 Subject: [PATCH] =?UTF-8?q?UI:=20=D1=81=D1=82=D0=B0=D0=B4=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B7=D0=B2=D1=83=D0=BA=20=D0=B7=D0=B2=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/services/call-service.js | 59 +++++++++++++++++++++++++ shine-UI/js/services/call-ui-service.js | 51 ++++++++++++++++++--- shine-UI/styles/components.css | 4 ++ 4 files changed, 109 insertions(+), 7 deletions(-) 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);