diff --git a/VERSION.properties b/VERSION.properties index b57baa38..b678cc14 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.12 +client.version=1.5.13 server.version=1.4.9 diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index 6e35c49e..a1036264 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -47,6 +47,12 @@ const OUTGOING_NO_ACK_TIMEOUT_MS = 10_000; const OUTGOING_DELIVERED_NO_ACK_TIMEOUT_MS = 25_000; const INCOMING_CONNECT_TIMEOUT_MS = 20_000; const MAX_TIMELINE_ENTRIES = 64; +const CAMERA_CONSTRAINTS = Object.freeze({ + width: { ideal: 1280 }, + height: { ideal: 720 }, + frameRate: { ideal: 24, max: 30 }, + facingMode: 'user', +}); function nowMs() { return Date.now(); @@ -624,12 +630,20 @@ function getCallStateSnapshot() { if (!call) return null; if (call.localUiDismissed) return null; const callPhase = String(call.phase || '').trim(); + const localVideoTracks = call.localStream?.getVideoTracks?.() || []; + const remoteVideoTracks = call.remoteMediaStream?.getVideoTracks?.() || []; const canToggleSpeaker = Boolean( callPhase !== 'incoming' && callPhase !== 'ended' && canRouteAudioOutputInBrowser() && call.remoteAudio ); + const canToggleCamera = Boolean( + callPhase !== 'incoming' + && callPhase !== 'ended' + && navigator?.mediaDevices?.getUserMedia + && call.pc + ); return { callId: call.callId, peerLogin: call.peerLogin || '', @@ -639,12 +653,18 @@ function getCallStateSnapshot() { startedAtMs: Number(call.startedAtMs || 0), connectedAtMs: Number(call.connectedAtMs || 0), muted: Boolean(call.muted), + cameraEnabled: Boolean(call.cameraEnabled), speakerEnabled: Boolean(call.speakerEnabled), canToggleSpeaker, + canToggleCamera, canAnswer: callPhase === 'incoming', canDecline: callPhase === 'incoming', canHangup: callPhase !== 'ended' && callPhase !== 'incoming', canMute: callPhase === 'active' || callPhase === 'connecting' || callPhase === 'ringing' || callPhase === 'reconnecting', + hasLocalVideo: localVideoTracks.some((track) => track?.readyState === 'live' && track?.enabled !== false), + hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live'), + localPreviewStream: call.localStream || null, + remoteMediaStream: call.remoteMediaStream || null, }; } @@ -714,6 +734,7 @@ function buildCallFactsJson(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0, timeline: buildTimelineSummary(call), ...extra, }; @@ -744,6 +765,7 @@ function buildCallFactsLine(call, extra = {}) { pcSignalingState: pc?.signalingState || '', hasLocalStream: Boolean(call?.localStream), localAudioTracksCount: call?.localStream?.getAudioTracks?.()?.length || 0, + localVideoTracksCount: call?.localStream?.getVideoTracks?.()?.length || 0, localTrackStates: summarizeMediaSnapshot(buildMediaSnapshot(call)), timeline: buildTimelineSummary(call), ...extra, @@ -757,12 +779,13 @@ function buildMediaSnapshot(call) { const localStream = call?.localStream || null; const localTracks = localStream?.getTracks?.() || []; const remoteAudio = call?.remoteAudio || null; - const remoteStream = remoteAudio?.srcObject || null; + const remoteStream = call?.remoteMediaStream || remoteAudio?.srcObject || null; const remoteTracks = remoteStream?.getTracks?.() || []; return { hasLocalStream: Boolean(localStream), localTrackCount: localTracks.length, localAudioTracksCount: localStream?.getAudioTracks?.()?.length || 0, + localVideoTracksCount: localStream?.getVideoTracks?.()?.length || 0, localTrackStates: localTracks.map((track) => ({ kind: String(track?.kind || ''), enabled: Boolean(track?.enabled), @@ -799,6 +822,7 @@ function summarizeMediaSnapshot(snapshot) { `localStream=${snapshot.hasLocalStream ? 1 : 0}`, `localTracks=${Number(snapshot.localTrackCount || 0)}`, `localAudio=${Number(snapshot.localAudioTracksCount || 0)}`, + `localVideo=${Number(snapshot.localVideoTracksCount || 0)}`, `localStates=${localSummary || '-'}`, `remoteAudio=${snapshot.hasRemoteAudio ? 1 : 0}`, `remoteSrc=${snapshot.hasRemoteAudioSrcObject ? 1 : 0}`, @@ -820,7 +844,9 @@ function getCallDiagnosticsContext(call) { : false; const localTracks = call?.localStream?.getTracks?.() || []; const localAudioTracks = call?.localStream?.getAudioTracks?.() || []; + const localVideoTracks = call?.localStream?.getVideoTracks?.() || []; const enabledLocalAudioTracks = localAudioTracks.filter((track) => track?.enabled).length; + const enabledLocalVideoTracks = localVideoTracks.filter((track) => track?.enabled).length; const transceiversCount = pc?.getTransceivers?.()?.length || 0; const sendersCount = pc?.getSenders?.()?.length || 0; const receiversCount = pc?.getReceivers?.()?.length || 0; @@ -849,6 +875,9 @@ function getCallDiagnosticsContext(call) { localAudioTracksCount: localAudioTracks.length, localAudioTracksEnabledCount: enabledLocalAudioTracks, localAudioTrackLabels: localAudioTracks.map((t) => String(t?.label || '')).join('|'), + localVideoTracksCount: localVideoTracks.length, + localVideoTracksEnabledCount: enabledLocalVideoTracks, + localVideoTrackLabels: localVideoTracks.map((t) => String(t?.label || '')).join('|'), localTrackStates: summarizeMediaSnapshot(mediaSnapshot), hasPeerConnection: Boolean(pc), pcConnectionState: pc?.connectionState || '', @@ -985,7 +1014,7 @@ async function closeMedia(call) { } catch {} try { if (call.remoteAudio) { - const remoteTracks = call.remoteAudio.srcObject?.getTracks?.() || []; + const remoteTracks = call.remoteMediaStream?.getTracks?.() || call.remoteAudio.srcObject?.getTracks?.() || []; remoteTracks.forEach((track) => { try { track.enabled = false; } catch {} try { track.stop(); } catch {} @@ -1000,6 +1029,9 @@ async function closeMedia(call) { try { pc?.close?.(); } catch {} call.pc = null; call.localStream = null; + call.localVideoTrack = null; + call.videoSender = null; + call.remoteMediaStream = null; call.audioSenders = []; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; @@ -1510,6 +1542,7 @@ async function ensurePeerConnection(call) { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); call.localStream = stream; call.audioSenders = []; + call.videoSender = null; call.connectionRouteLabel = ''; call.connectionRouteDetails = ''; recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`); @@ -1528,12 +1561,31 @@ async function ensurePeerConnection(call) { } pc.ontrack = (evt) => { - 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; + const trackKind = String(evt?.track?.kind || ''); + recordCallTimeline(call, 'remote_track_received', `kind=${trackKind || '-'}; streams=${evt?.streams?.length || 0}`); + let remoteStream = call.remoteMediaStream || null; + if (evt?.streams?.[0]) { + remoteStream = evt.streams[0]; + } else { + if (!remoteStream) remoteStream = new MediaStream(); + if (evt?.track && !remoteStream.getTracks().some((track) => track.id === evt.track.id)) { + remoteStream.addTrack(evt.track); + } + } + call.remoteMediaStream = remoteStream; + if (!call.remoteAudio) { + const audio = new Audio(); + audio.autoplay = true; + audio.playsInline = true; + call.remoteAudio = audio; + } + if (evt?.track) { + evt.track.onended = () => notifyCallState(); + evt.track.onmute = () => notifyCallState(); + evt.track.onunmute = () => notifyCallState(); + } + call.remoteAudio.srcObject = remoteStream; + void call.remoteAudio.play?.().catch?.(() => {}); notifyCallState(); }; @@ -1544,6 +1596,30 @@ async function ensurePeerConnection(call) { return pc; } +async function renegotiateCall(call, reason = '') { + if (!call?.pc || !call.remoteSessionId) return false; + if (call.negotiationInProgress) { + recordCallTimeline(call, 'renegotiate_skipped_busy', `reason=${reason || '-'}`); + return false; + } + call.negotiationInProgress = true; + try { + const pc = call.pc; + if (pc.signalingState !== 'stable' && pc.localDescription?.type === 'offer') { + recordCallTimeline(call, 'renegotiate_wait_unstable', `state=${pc.signalingState}; reason=${reason || '-'}`); + return false; + } + recordCallTimeline(call, 'renegotiate_start', `reason=${reason || '-'}`); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + await sendSignal(call, TYPES.OFFER, JSON.stringify(offer)); + recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${reason || '-'}`); + return true; + } finally { + call.negotiationInProgress = false; + } +} + async function onAccept(call) { if (!call) return; if (call.initialOfferInProgress || call.initialOfferSent) { @@ -1654,8 +1730,12 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) { connectedAtMs: 0, pc: null, localStream: null, + localVideoTrack: null, + remoteMediaStream: null, audioSenders: [], + videoSender: null, muted: false, + cameraEnabled: false, connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, @@ -1746,6 +1826,82 @@ async function applyMicState(call) { } } +async function ensureCameraTrack(call) { + const existingTrack = call?.localVideoTrack; + if (existingTrack && existingTrack.readyState === 'live') { + existingTrack.enabled = true; + return existingTrack; + } + const videoStream = await navigator.mediaDevices.getUserMedia({ video: CAMERA_CONSTRAINTS, audio: false }); + const videoTrack = videoStream.getVideoTracks?.()[0] || null; + if (!videoTrack) { + throw new Error('Не удалось получить видеотрек'); + } + const streamTracks = videoStream.getTracks?.() || []; + streamTracks.forEach((track) => { + if (track !== videoTrack) { + try { track.stop(); } catch {} + } + }); + if (!call.localStream) { + call.localStream = new MediaStream(); + } + const currentLocalVideoTracks = call.localStream.getVideoTracks?.() || []; + currentLocalVideoTracks.forEach((track) => { + if (track.id !== videoTrack.id) { + try { call.localStream.removeTrack(track); } catch {} + try { track.stop(); } catch {} + } + }); + call.localStream.addTrack(videoTrack); + call.localVideoTrack = videoTrack; + return videoTrack; +} + +async function disableCameraTrack(call) { + const videoTrack = call?.localVideoTrack || null; + if (videoTrack) { + try { videoTrack.enabled = false; } catch {} + try { call.localStream?.removeTrack?.(videoTrack); } catch {} + try { videoTrack.stop(); } catch {} + } + call.localVideoTrack = null; +} + +async function applyCameraState(call, { renegotiate = true, reason = '' } = {}) { + if (!call?.pc) return false; + const pc = call.pc; + const transceivers = pc.getTransceivers?.() || []; + let videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null; + if (call.cameraEnabled) { + const videoTrack = await ensureCameraTrack(call); + if (!videoTransceiver) { + const sender = pc.addTrack(videoTrack, call.localStream); + call.videoSender = sender; + videoTransceiver = transceivers.find((tr) => tr?.sender === sender) || null; + } else { + await videoTransceiver.sender.replaceTrack(videoTrack); + call.videoSender = videoTransceiver.sender; + } + if (videoTransceiver) { + try { videoTransceiver.direction = 'sendrecv'; } catch {} + } + videoTrack.enabled = true; + } else { + if (videoTransceiver) { + try { await videoTransceiver.sender.replaceTrack(null); } catch {} + call.videoSender = videoTransceiver.sender; + try { videoTransceiver.direction = 'recvonly'; } catch {} + } + await disableCameraTrack(call); + } + notifyCallState(); + if (renegotiate) { + await renegotiateCall(call, reason || (call.cameraEnabled ? 'camera_on' : 'camera_off')); + } + return true; +} + export async function setMicMuted(muted) { const call = getActiveCall(); if (!call) return; @@ -1760,6 +1916,29 @@ export async function toggleMicMuted() { await setMicMuted(!call.muted); } +export async function setCameraEnabled(enabled) { + const call = getActiveCall(); + if (!call || !call.pc) return false; + const nextEnabled = Boolean(enabled); + if (Boolean(call.cameraEnabled) === nextEnabled) return true; + call.cameraEnabled = nextEnabled; + try { + await applyCameraState(call, { renegotiate: true, reason: nextEnabled ? 'camera_on' : 'camera_off' }); + notifyCallState(); + return true; + } catch (error) { + call.cameraEnabled = !nextEnabled; + notifyCallState(); + throw error; + } +} + +export async function toggleCameraEnabled() { + const call = getActiveCall(); + if (!call) return false; + return setCameraEnabled(!call.cameraEnabled); +} + export async function toggleSpeakerMode() { const call = getActiveCall(); if (!call || !call.remoteAudio || !canRouteAudioOutputInBrowser()) return false; @@ -1810,8 +1989,12 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin connectedAtMs: 0, pc: null, localStream: null, + localVideoTrack: null, + remoteMediaStream: null, audioSenders: [], + videoSender: null, muted: false, + cameraEnabled: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), @@ -1849,8 +2032,12 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin connectedAtMs: 0, pc: null, localStream: null, + localVideoTrack: null, + remoteMediaStream: null, audioSenders: [], + videoSender: null, muted: false, + cameraEnabled: false, connectionRouteLabel: '', debugMode: true, debugRunId: String(runId || '').trim(), @@ -1902,11 +2089,15 @@ export async function startOutgoingCall(peerLogin) { timers: {}, startedAtMs: nowMs(), connectedAtMs: 0, - pc: null, - localStream: null, - audioSenders: [], - muted: false, - connectionRouteLabel: '', + pc: null, + localStream: null, + localVideoTrack: null, + remoteMediaStream: null, + audioSenders: [], + videoSender: null, + muted: false, + cameraEnabled: false, + connectionRouteLabel: '', reconnectInProgress: false, reconnectAttempts: 0, debugMode: false, @@ -2149,12 +2340,19 @@ export async function handleIncomingCallSignal(evt) { try { recordCallTimeline(call, 'offer_processing_start', `from=${fromSessionId || '-'}`); const pc = await ensurePeerConnection(call); - await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data))); + const incomingOffer = new RTCSessionDescription(JSON.parse(data)); + const localType = String(pc.localDescription?.type || '').trim().toLowerCase(); + if (pc.signalingState !== 'stable' && localType === 'offer') { + recordCallTimeline(call, 'offer_glare_rollback', `state=${pc.signalingState}; from=${fromSessionId || '-'}`); + await pc.setLocalDescription({ type: 'rollback' }); + } + await pc.setRemoteDescription(incomingOffer); await flushPendingIceCandidates(call); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer)); setStatus(call, 'Соединяем…', 'connecting'); + notifyCallState(); await emitDebug(call, 'info', 'offer_processed', 'answer sent'); } catch (error) { await emitDebug(call, 'error', 'offer_process_failed', toErrorText(error)); @@ -2187,6 +2385,7 @@ export async function handleIncomingCallSignal(evt) { await flushPendingIceCandidates(call); recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`); setStatus(call, 'Соединяем…', 'connecting'); + notifyCallState(); await emitDebug(call, 'info', 'answer_processed', 'remote description set'); } catch (error) { await emitDebug(call, 'error', 'answer_process_failed', toErrorText(error)); diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index 6b88253d..550a2b6b 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -2,6 +2,7 @@ import { acceptIncomingCall, declineIncomingCall, hangupActiveCall, + setCameraEnabled, setMicMuted, subscribeCallState, toggleSpeakerMode, @@ -15,10 +16,16 @@ let panelEl = null; let headerEl = null; let titleEl = null; let statusEl = null; +let mediaStageEl = null; +let remoteVideoEl = null; +let remoteVideoPlaceholderEl = null; +let localPreviewWrapEl = null; +let localVideoEl = null; let incomingActionsEl = null; let activeActionsEl = null; let speakerBtn = null; let muteBtn = null; +let cameraBtn = null; let acceptBtn = null; let declineBtn = null; let hangupBtn = null; @@ -28,6 +35,7 @@ let barTextEl = null; let barActionsEl = null; let barSpeakerBtn = null; let barMuteBtn = null; +let barCameraBtn = null; let barHangupBtn = null; let unbind = null; let tickerId = 0; @@ -87,6 +95,12 @@ function getIconSvg(name) { if (name === 'speaker-off') { return ''; } + if (name === 'camera-on') { + return ''; + } + if (name === 'camera-off') { + return ''; + } return ''; } @@ -113,6 +127,15 @@ function updateSpeakerButtons(enabled) { setButtonIcon(barSpeakerBtn, iconName, label); } +function updateCameraButtons(enabled) { + const iconName = enabled ? 'camera-on' : 'camera-off'; + const label = enabled ? 'Выключить камеру' : 'Включить камеру'; + setButtonIcon(cameraBtn, iconName, label); + setButtonIcon(barCameraBtn, iconName, label); + if (cameraBtn) cameraBtn.dataset.enabled = enabled ? '1' : '0'; + if (barCameraBtn) barCameraBtn.dataset.enabled = enabled ? '1' : '0'; +} + async function handleMuteClick(event) { stopEvent(event); const nowMuted = event.currentTarget?.dataset?.muted === '1'; @@ -134,6 +157,14 @@ async function handleSpeakerClick(event) { await toggleSpeakerMode(); } +async function handleCameraClick(event) { + stopEvent(event); + const enabled = event.currentTarget?.dataset?.enabled === '1'; + try { + await setCameraEnabled(!enabled); + } catch {} +} + function setMinimized(nextValue) { minimized = Boolean(nextValue); render(); @@ -185,12 +216,17 @@ function ensureUi() { barMuteBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; barMuteBtn.addEventListener('click', handleMuteClick); + barCameraBtn = document.createElement('button'); + barCameraBtn.type = 'button'; + barCameraBtn.className = 'call-icon-btn call-icon-btn--bar call-icon-btn--secondary'; + barCameraBtn.addEventListener('click', handleCameraClick); + 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); + barActionsEl.append(barSpeakerBtn, barMuteBtn, barCameraBtn, barHangupBtn); barEl.append(barTextEl, barActionsEl); overlayEl = document.createElement('div'); @@ -219,6 +255,31 @@ function ensureUi() { statusEl = document.createElement('div'); statusEl.className = 'call-overlay-status'; + mediaStageEl = document.createElement('div'); + mediaStageEl.className = 'call-video-stage'; + + remoteVideoEl = document.createElement('video'); + remoteVideoEl.className = 'call-remote-video'; + remoteVideoEl.autoplay = true; + remoteVideoEl.playsInline = true; + remoteVideoEl.muted = true; + + remoteVideoPlaceholderEl = document.createElement('div'); + remoteVideoPlaceholderEl.className = 'call-video-placeholder'; + remoteVideoPlaceholderEl.textContent = 'Собеседник пока без видео'; + + localPreviewWrapEl = document.createElement('div'); + localPreviewWrapEl.className = 'call-local-preview'; + + localVideoEl = document.createElement('video'); + localVideoEl.className = 'call-local-video'; + localVideoEl.autoplay = true; + localVideoEl.playsInline = true; + localVideoEl.muted = true; + + localPreviewWrapEl.append(localVideoEl); + mediaStageEl.append(remoteVideoEl, remoteVideoPlaceholderEl, localPreviewWrapEl); + incomingActionsEl = document.createElement('div'); incomingActionsEl.className = 'call-overlay-controls call-overlay-controls--incoming'; @@ -235,6 +296,11 @@ function ensureUi() { muteBtn.className = 'call-icon-btn call-icon-btn--secondary'; muteBtn.addEventListener('click', handleMuteClick); + cameraBtn = document.createElement('button'); + cameraBtn.type = 'button'; + cameraBtn.className = 'call-icon-btn call-icon-btn--secondary'; + cameraBtn.addEventListener('click', handleCameraClick); + acceptBtn = document.createElement('button'); acceptBtn.type = 'button'; acceptBtn.className = 'call-accept-btn'; @@ -258,9 +324,9 @@ function ensureUi() { hangupBtn.addEventListener('click', handleEndCallClick); incomingActionsEl.append(acceptBtn, declineBtn); - activeActionsEl.append(speakerBtn, muteBtn, hangupBtn); + activeActionsEl.append(speakerBtn, muteBtn, cameraBtn, hangupBtn); headerEl.append(titleEl, minimizeBtn); - panelEl.append(headerEl, statusEl, incomingActionsEl, activeActionsEl); + panelEl.append(headerEl, statusEl, mediaStageEl, incomingActionsEl, activeActionsEl); overlayEl.append(panelEl); rootEl.append(barEl, overlayEl); resolveAppShell().append(rootEl); @@ -277,6 +343,7 @@ function applyExpandedState(snapshot) { const muted = Boolean(snapshot.muted); updateMuteButtons(muted); updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); + updateCameraButtons(Boolean(snapshot.cameraEnabled)); minimizeBtn.hidden = incomingStage; minimizeBtn.disabled = incomingStage; @@ -290,9 +357,21 @@ function applyExpandedState(snapshot) { muteBtn.hidden = !snapshot.canMute; muteBtn.disabled = !snapshot.canMute; + cameraBtn.hidden = !snapshot.canToggleCamera; + cameraBtn.disabled = !snapshot.canToggleCamera; + acceptBtn.hidden = !incomingStage || !snapshot.canAnswer; declineBtn.hidden = !incomingStage || !snapshot.canDecline; hangupBtn.hidden = !snapshot.canHangup; + + mediaStageEl.hidden = incomingStage; + const remoteStream = snapshot.remoteMediaStream || null; + const localStream = snapshot.localPreviewStream || null; + if (remoteVideoEl.srcObject !== remoteStream) remoteVideoEl.srcObject = remoteStream; + if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream; + remoteVideoEl.hidden = !snapshot.hasRemoteVideo; + remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo; + localPreviewWrapEl.hidden = !snapshot.hasLocalVideo; } function applyMinimizedState(snapshot) { @@ -303,15 +382,18 @@ function applyMinimizedState(snapshot) { const muted = Boolean(snapshot.muted); updateMuteButtons(muted); updateSpeakerButtons(Boolean(snapshot.speakerEnabled)); + updateCameraButtons(Boolean(snapshot.cameraEnabled)); barSpeakerBtn.hidden = incomingStage || !snapshot.canToggleSpeaker; barSpeakerBtn.disabled = !snapshot.canToggleSpeaker; barMuteBtn.hidden = incomingStage || !snapshot.canMute; barMuteBtn.disabled = !snapshot.canMute; + barCameraBtn.hidden = incomingStage || !snapshot.canToggleCamera; + barCameraBtn.disabled = !snapshot.canToggleCamera; const canEnd = !incomingStage && snapshot.canHangup; barHangupBtn.hidden = !canEnd; barHangupBtn.disabled = !canEnd; setButtonIcon(barHangupBtn, 'hangup', 'Положить трубку'); - barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !canEnd); + barActionsEl.hidden = incomingStage || (!snapshot.canToggleSpeaker && !snapshot.canMute && !snapshot.canToggleCamera && !canEnd); } function syncShellState() { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 9387a337..3155e947 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -2001,6 +2001,56 @@ font-size: 14px; } +.call-video-stage { + position: relative; + width: 100%; + min-height: 220px; + border-radius: 16px; + overflow: hidden; + background: + radial-gradient(circle at top, rgba(41, 72, 118, 0.48), transparent 58%), + linear-gradient(180deg, rgba(7, 14, 27, 0.98), rgba(3, 7, 14, 1)); + border: 1px solid rgba(118, 154, 228, 0.2); +} + +.call-remote-video, +.call-local-video { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + background: #02050a; +} + +.call-remote-video[hidden], +.call-local-preview[hidden] { + display: none !important; +} + +.call-video-placeholder { + position: absolute; + inset: 0; + display: grid; + place-items: center; + padding: 18px; + color: rgba(223, 234, 255, 0.72); + font-size: 15px; + text-align: center; +} + +.call-local-preview { + position: absolute; + right: 12px; + bottom: 12px; + width: min(32vw, 128px); + aspect-ratio: 3 / 4; + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(176, 205, 255, 0.38); + background: rgba(7, 12, 22, 0.94); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32); +} + .call-overlay-controls { display: flex; flex-wrap: wrap;