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;