From 042345c6842ddcd4bd7c6c8f4886a892b4364f11 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Wed, 29 Jul 2026 12:14:22 +0200 Subject: [PATCH 1/2] fix(call): do not set redundant requests on input device change - client briefly stops previous track and requesting new one. During that window `updateCallFlags` might be triggered, as if user has completely disabled the camera and back - add a grace period of 1 second if flags bitmask goes in direction of removing bits Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Maksim Sukharev --- src/utils/webrtc/webrtc.js | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/utils/webrtc/webrtc.js b/src/utils/webrtc/webrtc.js index 91869c50964..b827efa15ff 100644 --- a/src/utils/webrtc/webrtc.js +++ b/src/utils/webrtc/webrtc.js @@ -525,6 +525,8 @@ export function initWebRtc(signaling, _callParticipantCollection, _localCallPart // is received before the "leave call" request ends. localUserInCall = false + clearTimeout(updateCallFlagsFromLocalMediaTimeout) + localStateBroadcaster.destroy() localStateBroadcaster = null }) @@ -1422,6 +1424,24 @@ export function initWebRtc(signaling, _callParticipantCollection, _localCallPart return callFlags } + let updateCallFlagsFromLocalMediaTimeout = null + + /** + * Update the call flags to match the current local tracks. + */ + function updateCallFlagsFromLocalMedia() { + // As update can be deferred, check whether the call may have been left in the meantime + if (!localUserInCall) { + return + } + + const callFlags = getCallFlagsFromLocalMedia() + + if (signaling.getCurrentCallFlags() !== callFlags) { + signaling.updateCurrentCallFlags(callFlags) + } + } + signaling.on('joinCall', function(token) { const expectedCallFlags = getCallFlagsFromLocalMedia() @@ -1463,6 +1483,8 @@ export function initWebRtc(signaling, _callParticipantCollection, _localCallPart } webrtc.on('localTrackReplaced', function(newTrack, oldTrack/* , stream */) { + clearTimeout(updateCallFlagsFromLocalMediaTimeout) + const callFlags = getCallFlagsFromLocalMedia() // A reconnection is not needed if a device is disabled or if there are @@ -1479,7 +1501,13 @@ export function initWebRtc(signaling, _callParticipantCollection, _localCallPart return } - if (signaling.getCurrentCallFlags() !== callFlags) { + const removedCallFlags = signaling.getCurrentCallFlags() & ~callFlags + if (removedCallFlags) { + // If a media is no longer sent because of "MediaDevicesManager._stopIncompatibleTracks()" + // (flags change 7->5->7 or 7->3->7 on input device replacement), defer the update + // and give some time for the new track to be set, then check local flags again. + updateCallFlagsFromLocalMediaTimeout = setTimeout(updateCallFlagsFromLocalMedia, 1_000) + } else if (signaling.getCurrentCallFlags() !== callFlags) { signaling.updateCurrentCallFlags(callFlags) } }) From de7aec4e9ec4f35f0289a0379566cf3b4d73586f Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Wed, 29 Jul 2026 11:31:46 +0200 Subject: [PATCH 2/2] fix(call): prevent last video frame leaking - when video track reassigned (change camera), connection shortly abrupts, call flags with no video being sent twice, and on reconnect video tile still holds reference to previos stream object (frozen last frame). - re-attach of video stream fixes that Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Maksim Sukharev --- src/components/CallView/shared/LocalVideo.vue | 13 ++++++++++++- src/components/CallView/shared/VideoVue.vue | 13 ++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/components/CallView/shared/LocalVideo.vue b/src/components/CallView/shared/LocalVideo.vue index e91b043e703..1c802b81d0d 100644 --- a/src/components/CallView/shared/LocalVideo.vue +++ b/src/components/CallView/shared/LocalVideo.vue @@ -252,6 +252,10 @@ export default { screenshotModeUrl() { return this.screenshotMode ? placeholderImage(8) : '' }, + + localStreamAttachIdentifier() { + return [this.localMediaModel.attributes.localStream, this.localMediaModel.attributes.videoAvailable] + }, }, watch: { @@ -269,7 +273,14 @@ export default { }, }, - 'localMediaModel.attributes.localStream': function(localStream) { + localStreamAttachIdentifier([localStream, videoAvailable], [oldLocalStream, oldVideoAvailable]) { + // Reattach the stream if it was replaced, or if the video is + // available again after the local track was replaced, to + // prevent rendering the last frame drawn before the change. + if (localStream === oldLocalStream && !(videoAvailable && !oldVideoAvailable)) { + return + } + this._setLocalStream(localStream) }, diff --git a/src/components/CallView/shared/VideoVue.vue b/src/components/CallView/shared/VideoVue.vue index 0f15b82c381..8f72452b68d 100644 --- a/src/components/CallView/shared/VideoVue.vue +++ b/src/components/CallView/shared/VideoVue.vue @@ -545,10 +545,21 @@ export default { screenshotModeUrl() { return this.screenshotMode ? placeholderImage(6) : '' }, + + streamAttachTrigger() { + return [this.model.attributes.stream, this.hasVideo] + }, }, watch: { - 'model.attributes.stream': function(stream) { + streamAttachTrigger([stream, hasVideo], [oldStream, oldHasVideo]) { + // Reattach the stream if it was replaced, or if the video is + // available again after the remote track was replaced, to + // prevent rendering the last frame received before the change. + if (stream === oldStream && !(hasVideo && !oldHasVideo)) { + return + } + this._setStream(stream) },