From 47b5f8db7b90246bd294069e3ecb5ee0a5cf78cca0501b6cf2a61233109c8b99 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Mon, 10 Aug 2026 15:00:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B4=D0=B2=D1=83=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=BD=D0=B5=D0=B5=20=D0=B2=D0=B8=D0=B4=D0=B5=D0=BE=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 2 +- shine-UI/js/services/call-service.js | 290 +++++++++++++++++++++--- shine-UI/js/services/call-ui-service.js | 3 +- shine-UI/js/services/dm-tech-blocks.js | 5 +- 4 files changed, 260 insertions(+), 40 deletions(-) diff --git a/VERSION.properties b/VERSION.properties index b678cc14..0c239cdf 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.13 +client.version=1.5.21 server.version=1.4.9 diff --git a/shine-UI/js/services/call-service.js b/shine-UI/js/services/call-service.js index a1036264..df2e154c 100644 --- a/shine-UI/js/services/call-service.js +++ b/shine-UI/js/services/call-service.js @@ -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(); diff --git a/shine-UI/js/services/call-ui-service.js b/shine-UI/js/services/call-ui-service.js index 550a2b6b..f83485c4 100644 --- a/shine-UI/js/services/call-ui-service.js +++ b/shine-UI/js/services/call-ui-service.js @@ -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; diff --git a/shine-UI/js/services/dm-tech-blocks.js b/shine-UI/js/services/dm-tech-blocks.js index 2ba76fe3..0a2616cb 100644 --- a/shine-UI/js/services/dm-tech-blocks.js +++ b/shine-UI/js/services/dm-tech-blocks.js @@ -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',