SHA256
Добавить базовую поддержку видеозвонка
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user