Black screen on remote participant after screen share ends

The Problem

When one participant stops sharing their screen, the other participant sees a black screen in the main video area. However, if the affected participant then clicks the PIP thumbnail to swap views, the remote video appears correctly.

This strongly suggests the issue is not a video stream problem (the stream is active) but rather an attachment/target timing issue in the active-share-change handler.

Our Analysis

The active-share-change Inactive Handler

When screen sharing ends, the Zoom Video SDK fires active-share-change with state: “Inactive” on both participants. Our handler (in VideoCall.jsx ) does the following:

// Re-attach both feeds to whichever layout is currently pinned
const nextPinnedView = pinnedViewRef.current;
const allUsers = client.getAllUser();
const myId = client.getCurrentUserInfo().userId;
const remoteUser = allUsers.find((u) => u.userId !== myId);

await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);

// Re-attach local video
const localTargetSelector = nextPinnedView === "local"
? "#local-video-player"
: "#local-video-pip-player";
const localTarget = await waitForVideoPlayerReady(localTargetSelector, 3000);
if (localTarget) {
try { await stream.detachVideo(myId); } catch (_) {}
try {
await stream.attachVideo(
myId, VideoQuality.Video_720P, localTarget,
);
} catch (e) {
console.warn("[VideoCall] Failed to re-attach local video after share ended:", e);
}
}

// Re-attach remote video
if (remoteUser) {
const remoteTargetSelector = nextPinnedView === "local"
? "#remote-video-thumb-player"
: "#remote-video-player";
const remoteTarget = await waitForVideoPlayerReady(remoteTargetSelector, 3000);
try { await stream.detachVideo(remoteUser.userId); } catch (_) {}

if (remoteUser.bVideoOn && remoteTarget) {
try {
await stream.attachVideo(
remoteUser.userId, VideoQuality.Video_720P, remoteTarget,
);
} catch (e) {
console.warn("[VideoCall] Failed to re-attach remote video after share ended:", e);
}
}
}
}

Suspected Root Cause #1: bVideoOn Stale State

When screen sharing stops, the remote sharer’s bVideoOn property (checked via client.getAllUser()) may briefly be false during the SDK’s internal transition from share mode back to camera mode. Our guard:

// javascript
if (remoteUser.bVideoOn && remoteTarget) {

prevents the attachVideo call if bVideoOn is false at that moment. The video is never re-attached to the main player, leaving a black screen.

The user then manually swaps the view (triggers handlePinnedViewChange), which re-reads bVideoOn — by then it’s true — and attaches successfully.

This is our leading hypothesis. The bVideoOn check was intended to avoid attaching a null video, but it creates a race where the brief window between share-end and camera-restore causes a permanent black screen.

Suspected Root Cause #2: stopShareView() Timing

stopShareView() is called before re-attaching video feeds. If the SDK hasn’t fully cleaned up the share view before attachVideo runs, the attachment may silently fail or target a transitional state.

Suspected Root Cause #3: React CSS Transition Race

The main video container has a CSS transition:

// jsx
className={absolute inset-0 transition-all duration-300 ${ callStarted && !(remoteIsSharing || isScreenSharing) && pinnedView === "remote" ? "opacity-100" : "pointer-events-none opacity-0" }}

When setRemoteIsSharing(false) triggers a re-render immediately inside the Inactive handler, the container’s opacity transitions from 0 to 1 over 300ms. The attachVideo call runs before this transition completes — the element exists but is transitioning. While the SDK should render regardless of CSS opacity, this timing could compound with issues #1 or #2.

Questions

  1. Is there a documented state transition for bVideoOn when screen sharing stops? Specifically, does getAllUser().bVideoOn briefly return false between “sharing stopped” and “camera resumed”?
  2. Should stopShareView() be awaited before calling attachVideo() / detachVideo()? Is there a required cooldown or lifecycle step after stopShareView() before video streams can be re-established?
  3. Can you suggest the recommended pattern for re-attaching video feeds after screen sharing ends? Specifically:
  • Should we use a peer-video-state-change event with action: “Start” as a trigger to re-attach, rather than doing it immediately in the active-share-change Inactive handler?
  • Is there an event that fires specifically when the sharer’s camera feed is fully restored after sharing ends?
  1. What is the expected value of bVideoOn for a participant who was sharing and has an active camera? Does bVideoOn remain true during sharing (since camera is still active) or does it toggle off temporarily?

Relevant Code for Context
The DOM layout on the receiver’s side when screen sharing is active (from VideoCallInCall.jsx):

{/ Remote video — hidden during share, shown when share ends /}

{/ Always-present canvas for remote share view /}
<canvas id="screen-share-view-canvas" className={ absolute inset-0 h-full w-full ${
remoteIsSharing ? "" : "pointer-events-none opacity-0"
}} />

Hey @Gatsby

Thanks for your feedback.

  1. Is there a documented state transition for bVideoOn when screen sharing stops? Specifically, does getAllUser().bVideoOn briefly return false between “sharing stopped” and “camera resumed”?

bVideoOn does not change when screen sharing starts or stops, because video and screen sharing are two separate features.

  1. Should stopShareView() be awaited before calling attachVideo() / detachVideo()? Is there a required cooldown or lifecycle step after stopShareView() before video streams can be re-established?

There is no need to wait.

  1. Can you suggest the recommended pattern for re-attaching video feeds after screen sharing ends?

I’m not sure about your use case, but when screen sharing starts, do you actively call stopVideo to turn off video? If not, then peer-video-state-change will not be triggered. If you need to re-render, you should still decide whether to render the video based on the value returned by getAllUser().bVideoOn.

  1. What is the expected value of bVideoOn for a participant who was sharing and has an active camera? Does bVideoOn remain true during sharing (since camera is still active) or does it toggle off temporarily?

If stopVideo is not called, the bVideoOn value will always remain true.

If possible, could you provide the affected session IDs so we can use the logs to identify the root cause more quickly?

Thanks
Vic

Hi Vi,

Thank you for the clarification. A few follow-ups based on your answers.

  1. We do NOT call stopVideo() during screen sharing

To answer your question: no, we do not call stopVideo() when screen sharing starts. The camera remains active the entire time. On the receiver’s side, we simply move the remote participant’s camera feed to a different element using detachVideo() → attachVideo() during the active-share-change “Active” event. Since stopVideo() is never called, we understand that peer-video-state-change will not fire — which is fine.

This also means bVideoOn from getAllUser() remains true throughout, as you confirmed. Our guard accessing bVideoOn is never the blocker.

  1. The real issue: detachVideo + attachVideo fails after “Inactive”

Since the video stream never stops, the problem narrows to this specific sequence:

When active-share-change fires with state: “Inactive” :

  1. stopShareView() // cleanup the share canvas
  2. detachVideo(remoteUserId) // remove from thumbnail player
  3. attachVideo(remoteUserId, 720P, mainPlayer) // re-attach to main player

Step 3 renders a black frame on the main element. No errors are thrown — the SDK reports success, the element is present in the DOM, and customElements.whenDefined(‘video-player’) has already resolved (since the element was defined at initial join).

However, if the user then clicks our “swap view” button — which runs another detachVideo() + attachVideo() cycle — the video appears correctly on the second attempt.

This pattern strongly suggests the Zoom Video SDK’s internal video pipeline does not fully transition from share-mode back to camera-mode after the first attachVideo() call following an active-share-change “Inactive” event.

  1. Regarding the session IDs

Regarding your request for affected session IDs — could you clarify what format or identifier would be most useful?

Our app uses the Zoom Video SDK exclusively (not the Zoom Meetings API). Session names in our system follow a deterministic format based on our internal booking IDs (e.g., gigant-booking-143 ), which we store in our database. We don’t generate or store traditional Zoom meeting numbers since we use the Video SDK’s on-demand sessions.

Would providing our session name string (e.g., gigant-booking-143 ) and the approximate timestamp be sufficient for your team to look up the corresponding logs? Alternatively, would our Zoom Video SDK app credentials (SDK Key / SDK Client ID) help you correlate on your end?

Happy to provide whatever format works best for your system.

Follow-up question:

Is there any known behavior where detachVideo() followed immediately by attachVideo() on a different target renders a black frame when called right after an active-share-change “Inactive” event? The fact that a second detach+attach cycle always works suggests the first may be hitting an internal transitional state in the SDK.

Alternatively, is there a recommended approach for this scenario where we can avoid the detach/re-attach entirely — for example, keeping the camera feed on its original element during sharing and simply layering the share view on top?

Best regards,
Rod