ENESFRPT

Depth in everything. Superficiality in nothing.

srcObject is DOM state, not a prop

Your own square is black and the other person sees you perfectly. That asymmetry is the whole diagnosis: the track is being negotiated and sent, so the camera works and the connection works. What failed is local, and it is that srcObject is DOM state rather than a prop, so React never restores it.

Why it fails on some entries and not others

A call screen has phases. Waiting to be admitted, connecting, connected, ended, reconnecting. Several of them return early without rendering the video elements at all. Every one of those early returns destroys the element, and the next phase mounts a fresh node whose srcObject is null.

So binding the stream once, inside the getUserMedia promise, works exactly when a video element happens to exist at that instant. It misses whenever the camera opens on a screen that has none: the guest waiting for the host to admit them, and every rejoin from the ended or lost screen. Nothing throws, because assigning to a ref that is null is not an error, it is a no-op.

Why it looks like a camera problem and is not

The stream is stored either way. The tracks are live, they are added to the peer connection, and they are sent. That is why the far side is unaffected, and why the first instinct, checking permissions and devices, finds nothing wrong. The bug is entirely in the last few centimetres, between a MediaStream that exists and a video element that was never told about it.

Bind in both directions

There are two events and no guaranteed order: the stream arrives, and the element mounts. Handle both. A callback ref pulls the current stream whenever a node mounts, and a setter pushes the stream to the current node whenever it arrives. Whichever happens second does the binding.

// Bound in both directions, so whichever arrives second does the work.
const el = useRef<HTMLVideoElement | null>(null);
const want = useRef<MediaStream | null>(null);

const paint = useCallback((node: HTMLVideoElement | null) => {
  if (!node || node.srcObject === want.current) return;
  node.srcObject = want.current;
  if (want.current) void node.play().catch(() => undefined);
}, []);

// Callback ref: pulls, when a new element mounts.
const videoRef = useCallback((node) => { el.current = node; paint(node); }, [paint]);
// Setter: pushes, when the stream arrives.
const showPreview = useCallback((s) => { want.current = s; paint(el.current); }, [paint]);

The equality check matters. Without it, every re-render reassigns srcObject, and reassigning restarts the element, which produces a visible flicker on a preview that was already working.

Testing it so it stays fixed

Asserting that srcObject is not null is not enough. A stream whose tracks have been stopped stays bound and still paints nothing, so the assertion passes on a black square. Wait for decoded frames instead: videoWidth above zero, the element not paused, and at least one track still in the live state.

  • Do not go back to a bare if (ref.current). It is correct on the path you are testing and wrong on the ones you are not.
  • Clear the stored stream on cleanup, or a stale one gets painted into the next mount.
  • Test the entry that has a waiting screen, not just the direct join. The direct join is the path that already worked.
  • speechSynthesis cannot be captured

    The Web Speech API hands you no MediaStreamTrack, so a spoken translation can never go into a WebRTC sender. Here is the route that does work.

  • Translator.create needs a real click

    Chrome's Translator API requires transient user activation whenever the language pack still has to download, which is every fresh profile. Warm it in the click.

Working on something that has to hold?

If you are somewhere in the gap between a demo and a system real people depend on, that is the part I do.