Добавить базовую поддержку видеозвонка

This commit is contained in:
AidarKC
2026-08-10 13:53:04 +04:00
parent 37e8ea27d0
commit 0e763987aa
4 changed files with 350 additions and 19 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.5.12
client.version=1.5.13
server.version=1.4.9
+204 -5
View File
@@ -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 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;
audio.srcObject = evt.streams[0];
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(),
@@ -1904,8 +2091,12 @@ export async function startOutgoingCall(peerLogin) {
connectedAtMs: 0,
pc: null,
localStream: null,
localVideoTrack: null,
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
muted: false,
cameraEnabled: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
@@ -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));
+86 -4
View File
@@ -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 '<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>';
}
if (name === 'camera-on') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>';
}
if (name === 'camera-off') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8.5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M16 10.2 20 8v8l-4-2.2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="M5 5l14 14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/></svg>';
}
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() {
+50
View File
@@ -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;