Починить двустороннее видео в звонках

This commit is contained in:
AidarKC
2026-08-10 15:00:44 +04:00
parent 0e763987aa
commit 47b5f8db7b
4 changed files with 260 additions and 40 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
client.version=1.5.13
client.version=1.5.21
server.version=1.4.9
+253 -37
View File
@@ -661,9 +661,14 @@ function getCallStateSnapshot() {
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,
hasLocalVideo: Boolean(
call.cameraEnabled
&& call.localVideoTrack
&& call.localVideoTrack.readyState === 'live'
&& call.localVideoTrack.enabled !== false
),
hasRemoteVideo: remoteVideoTracks.some((track) => track?.readyState === 'live' && !track?.muted),
localPreviewStream: call.cameraEnabled ? (call.localStream || null) : null,
remoteMediaStream: call.remoteMediaStream || null,
};
}
@@ -701,7 +706,18 @@ function setStatus(call, statusText, phase = '') {
function buildActiveStatusText(call) {
const route = String(call?.connectionRouteLabel || '').trim();
return route ? `Разговор идёт (${route})` : 'Разговор идёт';
const videoSlotMarker = call?.videoSlotReady ? ' +' : '';
return route ? `Разговор идёт (${route}${videoSlotMarker})` : 'Разговор идёт';
}
function refreshVideoSlotReady(call) {
if (!call?.pc) return false;
const localSdp = String(call.pc.localDescription?.sdp || '');
const remoteSdp = String(call.pc.remoteDescription?.sdp || '');
const hasVideoInLocal = localSdp.includes('\nm=video') || localSdp.startsWith('m=video');
const hasVideoInRemote = remoteSdp.includes('\nm=video') || remoteSdp.startsWith('m=video');
call.videoSlotReady = Boolean(hasVideoInLocal && hasVideoInRemote && call.videoTransceiver);
return call.videoSlotReady;
}
function setActiveStatus(call) {
@@ -1416,6 +1432,75 @@ async function notifyPeerSessionsAboutConnectStart(call) {
}
}
function ensureReservedVideoTransceiver(call) {
const pc = call?.pc || null;
if (!pc) return null;
if (call.videoTransceiver) return call.videoTransceiver;
const transceivers = pc.getTransceivers?.() || [];
const existing = transceivers.find((tr) => tr?.mid !== null && tr?.mid !== undefined && (tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video'))
|| transceivers.find((tr) => tr?.receiver?.track?.kind === 'video' || tr?.sender?.track?.kind === 'video')
|| null;
if (existing) {
call.videoTransceiver = existing;
call.videoSender = existing?.sender || call.videoSender || null;
return existing;
}
const created = pc.addTransceiver('video', { direction: 'sendrecv' });
call.videoTransceiver = created;
call.videoSender = created?.sender || null;
return created;
}
function ensureRemoteTrackObservers(call, track) {
if (!call || !track || typeof track !== 'object') return;
if (!call.remoteTrackObserverIds) {
call.remoteTrackObserverIds = new Set();
}
if (call.remoteTrackObserverIds.has(track.id)) return;
call.remoteTrackObserverIds.add(track.id);
track.onended = () => {
if (track.kind === 'video' && call.remoteMediaStream) {
try { call.remoteMediaStream.removeTrack(track); } catch {}
}
notifyCallState();
};
track.onmute = () => notifyCallState();
track.onunmute = () => notifyCallState();
}
function ensureRemoteMediaStream(call) {
if (call?.remoteMediaStream) return call.remoteMediaStream;
const stream = new MediaStream();
if (call) {
call.remoteMediaStream = stream;
}
return stream;
}
function syncRemoteReceiverVideoTracks(call) {
const pc = call?.pc || null;
if (!pc) return false;
const remoteStream = ensureRemoteMediaStream(call);
let changed = false;
const receivers = pc.getReceivers?.() || [];
receivers.forEach((receiver) => {
const track = receiver?.track || null;
if (!track || track.kind !== 'video' || track.readyState === 'ended') return;
const hasTrack = remoteStream.getVideoTracks().some((existing) => existing.id === track.id);
if (!hasTrack) {
try {
remoteStream.addTrack(track);
changed = true;
} catch {}
}
ensureRemoteTrackObservers(call, track);
});
if (call.remoteAudio) {
call.remoteAudio.srcObject = remoteStream;
}
return changed;
}
async function ensurePeerConnection(call) {
if (call.pc) return call.pc;
@@ -1479,6 +1564,7 @@ async function ensurePeerConnection(call) {
if (!call.connectedAtMs) {
call.connectedAtMs = nowMs();
}
refreshVideoSlotReady(call);
setActiveStatus(call);
startTransportProbe(call);
void emitDebug(call, 'info', 'peer_connection_connected', `callId=${call.callId}`);
@@ -1538,11 +1624,25 @@ async function ensurePeerConnection(call) {
}
};
pc.onsignalingstatechange = () => {
const state = String(pc.signalingState || '');
recordCallTimeline(call, 'pc_signaling_state', `state=${state || ''}`);
if (state === 'stable') {
if (syncRemoteReceiverVideoTracks(call)) {
notifyCallState();
}
}
if (state === 'stable' && call.pendingNegotiationReason) {
void renegotiateCall(call, `signaling_stable:${call.pendingNegotiationReason}`);
}
};
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
call.localStream = stream;
call.audioSenders = [];
call.videoSender = null;
call.videoTransceiver = null;
call.connectionRouteLabel = '';
call.connectionRouteDetails = '';
recordCallTimeline(call, 'local_media_ready', `tracks=${stream.getTracks().length}`);
@@ -1553,6 +1653,24 @@ async function ensurePeerConnection(call) {
call.audioSenders.push(sender);
}
});
const blackTrack = ensureBlackVideoTrack(call);
if (blackTrack) {
const existingVideoTracks = stream.getVideoTracks?.() || [];
existingVideoTracks.forEach((track) => {
if (track.id !== blackTrack.id) {
try { stream.removeTrack(track); } catch {}
}
});
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
stream.addTrack(blackTrack);
}
const sender = pc.addTrack(blackTrack, stream);
call.videoSender = sender;
call.videoTransceiver = (pc.getTransceivers?.() || []).find((tr) => tr?.sender === sender) || null;
bindVideoSenderStream(call, sender);
} else {
ensureReservedVideoTransceiver(call);
}
} catch (e) {
setStatus(call, `Нет доступа к микрофону: ${e?.message || 'unknown'}`, 'failed');
recordCallTimeline(call, 'local_media_failed', toErrorText(e), 'warn');
@@ -1573,18 +1691,18 @@ async function ensurePeerConnection(call) {
}
}
call.remoteMediaStream = remoteStream;
if (trackKind === 'video') {
call.videoEverActivated = true;
}
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();
}
if (evt?.track) ensureRemoteTrackObservers(call, evt.track);
call.remoteAudio.srcObject = remoteStream;
syncRemoteReceiverVideoTracks(call);
void call.remoteAudio.play?.().catch?.(() => {});
notifyCallState();
};
@@ -1596,27 +1714,47 @@ async function ensurePeerConnection(call) {
return pc;
}
function queueNegotiationReason(call, reason = '') {
if (!call) return '';
const normalized = String(reason || '').trim() || 'pending';
call.pendingNegotiationReason = normalized;
return normalized;
}
async function renegotiateCall(call, reason = '') {
if (!call?.pc || !call.remoteSessionId) return false;
const queuedReason = queueNegotiationReason(call, reason);
if (call.negotiationInProgress) {
recordCallTimeline(call, 'renegotiate_skipped_busy', `reason=${reason || '-'}`);
recordCallTimeline(call, 'renegotiate_queued_busy', `reason=${queuedReason || '-'}`);
return false;
}
const pc = call.pc;
if (pc.signalingState !== 'stable') {
recordCallTimeline(call, 'renegotiate_queued_unstable', `state=${pc.signalingState}; reason=${queuedReason || '-'}`);
return false;
}
const activeReason = String(call.pendingNegotiationReason || queuedReason || 'pending').trim() || 'pending';
call.pendingNegotiationReason = '';
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 || '-'}`);
recordCallTimeline(call, 'renegotiate_start', `reason=${activeReason || '-'}`);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await sendSignal(call, TYPES.OFFER, JSON.stringify(offer));
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${reason || '-'}`);
recordCallTimeline(call, 'renegotiate_offer_sent', `reason=${activeReason || '-'}`);
return true;
} catch (error) {
if (!call.pendingNegotiationReason) {
call.pendingNegotiationReason = activeReason;
}
throw error;
} finally {
call.negotiationInProgress = false;
if (call.pendingNegotiationReason && call.pc?.signalingState === 'stable') {
queueMicrotask(() => {
void renegotiateCall(call, `queued:${call.pendingNegotiationReason}`);
});
}
}
}
@@ -1633,6 +1771,7 @@ async function onAccept(call) {
try {
await notifyPeerSessionsAboutConnectStart(call);
const pc = await ensurePeerConnection(call);
ensureReservedVideoTransceiver(call);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
call.initialOfferSent = true;
@@ -1734,8 +1873,10 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
@@ -1743,6 +1884,7 @@ async function handleIncomingInvitePayload(payload, { source = 'ws' } = {}) {
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
@@ -1855,9 +1997,34 @@ async function ensureCameraTrack(call) {
});
call.localStream.addTrack(videoTrack);
call.localVideoTrack = videoTrack;
call.videoEverActivated = true;
return videoTrack;
}
function ensureBlackVideoTrack(call) {
const existingTrack = call?.placeholderBlackVideoTrack || null;
if (existingTrack && existingTrack.readyState === 'live') {
existingTrack.enabled = true;
return existingTrack;
}
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 360;
const ctx = canvas.getContext('2d');
if (!ctx || typeof canvas.captureStream !== 'function') {
return null;
}
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const stream = canvas.captureStream(1);
const track = stream.getVideoTracks?.()[0] || null;
if (!track) return null;
call.placeholderBlackCanvas = canvas;
call.placeholderBlackStream = stream;
call.placeholderBlackVideoTrack = track;
return track;
}
async function disableCameraTrack(call) {
const videoTrack = call?.localVideoTrack || null;
if (videoTrack) {
@@ -1868,37 +2035,71 @@ async function disableCameraTrack(call) {
call.localVideoTrack = null;
}
async function applyCameraState(call, { renegotiate = true, reason = '' } = {}) {
function bindVideoSenderStream(call, sender) {
if (!call?.localStream || !sender || typeof sender.setStreams !== 'function') return;
try {
sender.setStreams(call.localStream);
} catch {}
}
async function applyCameraState(call) {
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;
let videoTransceiver = call.videoTransceiver || null;
if (!videoTransceiver) {
const transceivers = pc.getTransceivers?.() || [];
videoTransceiver = transceivers.find((tr) => tr?.sender?.track?.kind === 'video' || tr?.receiver?.track?.kind === 'video') || null;
call.videoTransceiver = videoTransceiver;
call.videoSender = videoTransceiver?.sender || call.videoSender || 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 {
videoTransceiver = pc.addTransceiver('video', { direction: 'sendrecv' });
call.videoTransceiver = videoTransceiver;
call.videoSender = videoTransceiver?.sender || null;
}
try {
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
videoTransceiver.direction = 'sendrecv';
}
} catch {}
if (videoTransceiver?.sender) {
await videoTransceiver.sender.replaceTrack(videoTrack);
call.videoSender = videoTransceiver.sender;
}
if (videoTransceiver) {
try { videoTransceiver.direction = 'sendrecv'; } catch {}
bindVideoSenderStream(call, videoTransceiver.sender);
} else {
const sender = pc.addTrack(videoTrack, call.localStream);
call.videoSender = sender;
bindVideoSenderStream(call, sender);
}
videoTrack.enabled = true;
} else {
if (videoTransceiver) {
try { await videoTransceiver.sender.replaceTrack(null); } catch {}
const blackTrack = ensureBlackVideoTrack(call);
if (call.localStream && blackTrack) {
const existingVideoTracks = call.localStream.getVideoTracks?.() || [];
existingVideoTracks.forEach((track) => {
if (track.id !== blackTrack.id) {
try { call.localStream.removeTrack(track); } catch {}
}
});
if (!existingVideoTracks.some((track) => track.id === blackTrack.id)) {
call.localStream.addTrack(blackTrack);
}
}
try {
if (videoTransceiver && videoTransceiver.direction !== 'sendrecv') {
videoTransceiver.direction = 'sendrecv';
}
} catch {}
if (videoTransceiver?.sender) {
try { await videoTransceiver.sender.replaceTrack(blackTrack || null); } catch {}
call.videoSender = videoTransceiver.sender;
try { videoTransceiver.direction = 'recvonly'; } catch {}
bindVideoSenderStream(call, videoTransceiver.sender);
}
await disableCameraTrack(call);
}
notifyCallState();
if (renegotiate) {
await renegotiateCall(call, reason || (call.cameraEnabled ? 'camera_on' : 'camera_off'));
}
return true;
}
@@ -1923,7 +2124,7 @@ export async function setCameraEnabled(enabled) {
if (Boolean(call.cameraEnabled) === nextEnabled) return true;
call.cameraEnabled = nextEnabled;
try {
await applyCameraState(call, { renegotiate: true, reason: nextEnabled ? 'camera_on' : 'camera_off' });
await applyCameraState(call);
notifyCallState();
return true;
} catch (error) {
@@ -1993,13 +2194,16 @@ export async function startDebugConnectionAsResponder({ runId, callId, peerLogin
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
debugMode: true,
debugRunId: String(runId || '').trim(),
debugRole: 'responder',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
};
@@ -2036,13 +2240,16 @@ export async function startDebugConnectionAsInitiator({ runId, callId, peerLogin
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
debugMode: true,
debugRunId: String(runId || '').trim(),
debugRole: 'initiator',
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
@@ -2095,17 +2302,20 @@ export async function startOutgoingCall(peerLogin) {
remoteMediaStream: null,
audioSenders: [],
videoSender: null,
videoTransceiver: null,
muted: false,
cameraEnabled: false,
videoEverActivated: false,
connectionRouteLabel: '',
reconnectInProgress: false,
reconnectAttempts: 0,
debugMode: false,
debugRunId: '',
debugRole: '',
pendingRemoteIceCandidates: [],
initialOfferInProgress: false,
initialOfferSent: false,
pendingRemoteIceCandidates: [],
pendingNegotiationReason: '',
initialOfferInProgress: false,
initialOfferSent: false,
timelineEvents: [],
timelineSeq: 0,
timelineBaseMs: nowMs(),
@@ -2347,10 +2557,13 @@ export async function handleIncomingCallSignal(evt) {
await pc.setLocalDescription({ type: 'rollback' });
}
await pc.setRemoteDescription(incomingOffer);
ensureReservedVideoTransceiver(call);
syncRemoteReceiverVideoTracks(call);
await flushPendingIceCandidates(call);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await sendSignal(call, TYPES.ANSWER, JSON.stringify(answer));
refreshVideoSlotReady(call);
setStatus(call, 'Соединяем…', 'connecting');
notifyCallState();
await emitDebug(call, 'info', 'offer_processed', 'answer sent');
@@ -2382,7 +2595,10 @@ export async function handleIncomingCallSignal(evt) {
return;
}
await pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)));
ensureReservedVideoTransceiver(call);
syncRemoteReceiverVideoTracks(call);
await flushPendingIceCandidates(call);
refreshVideoSlotReady(call);
recordCallTimeline(call, 'answer_applied', `from=${fromSessionId || '-'}`);
setStatus(call, 'Соединяем…', 'connecting');
notifyCallState();
+2 -1
View File
@@ -367,7 +367,8 @@ function applyExpandedState(snapshot) {
mediaStageEl.hidden = incomingStage;
const remoteStream = snapshot.remoteMediaStream || null;
const localStream = snapshot.localPreviewStream || null;
if (remoteVideoEl.srcObject !== remoteStream) remoteVideoEl.srcObject = remoteStream;
const remoteVideoSource = snapshot.hasRemoteVideo ? remoteStream : null;
if (remoteVideoEl.srcObject !== remoteVideoSource) remoteVideoEl.srcObject = remoteVideoSource;
if (localVideoEl.srcObject !== localStream) localVideoEl.srcObject = localStream;
remoteVideoEl.hidden = !snapshot.hasRemoteVideo;
remoteVideoPlaceholderEl.hidden = snapshot.hasRemoteVideo;
+4 -1
View File
@@ -118,7 +118,10 @@ export function parseDmTechBlocks(rawText = '') {
const status = String(fields.status || '').trim().toLowerCase();
if (status === 'completed') {
const durationSec = Math.max(0, Math.floor(Number(fields.duration || 0)));
callSummary = { status: 'completed', durationSec };
callSummary = {
status: 'completed',
durationSec,
};
} else {
callSummary = {
status: 'failed',