From c1ecdd80cc648308fdd54003da981f9d6b534f09 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 13 Nov 2026 09:54:54 -0600 Subject: [PATCH] hold the last frame on screen while switching live streams Switching streams in the single camera view blanked the player until the new stream played, because the old player unmounts in the same render and the hidden new player gave the card no height. The outgoing video's last frame is now drawn to a canvas before the remount and covers the gap until the new stream plays, and the card takes the camera's aspect ratio so it keeps its size while the video is hidden. --- web/src/components/player/LivePlayer.tsx | 31 ++++++++++++++++++++++++ web/src/views/live/LiveCameraView.tsx | 14 ++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx index ff73e047fa..d4d884987e 100644 --- a/web/src/components/player/LivePlayer.tsx +++ b/web/src/components/player/LivePlayer.tsx @@ -248,10 +248,18 @@ export default function LivePlayer({ // remounts them in the same render and the new player is hidden until it // plays. jsmpeg plays the camera, not the stream, and keeps playing const [renderedStream, setRenderedStream] = useState(streamName); + const [switchFrame, setSwitchFrame] = useState(); if (renderedStream !== streamName) { setRenderedStream(streamName); if (preferredLiveMode !== "jsmpeg") { setLiveReady(false); + + // the old video is still in the DOM until this render commits, so its + // last frame can cover the gap until the new stream plays + const frame = captureVideoFrame(overlayRef.current); + if (frame) { + setSwitchFrame(frame); + } } } @@ -263,6 +271,7 @@ export default function LivePlayer({ const playerIsPlaying = useCallback(() => { setLiveReady(true); + setSwitchFrame(undefined); }, []); // enabled states @@ -416,6 +425,12 @@ export default function LivePlayer({ )} {player} + {switchFrame && !liveReady && ( +
node?.replaceChildren(switchFrame)} + /> + )} {cameraEnabled && !offline && (!showStillWithoutActivity || isReEnabling) && @@ -582,3 +597,19 @@ export default function LivePlayer({
); } + +function captureVideoFrame( + container: HTMLElement | null, +): HTMLCanvasElement | undefined { + const video = container?.querySelector("video"); + if (!video || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) { + return undefined; + } + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.className = "size-full object-contain"; + canvas.getContext("2d")?.drawImage(video, 0, 0); + return canvas; +} diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 8252a6692e..0e107f8153 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -873,9 +873,12 @@ export default function LiveCameraView({ growClassName, )} ref={clickOverlayRef} - style={{ - aspectRatio: constrainedAspectRatio, - }} + style={ + { + aspectRatio: constrainedAspectRatio, + "--camera-aspect": cameraAspectRatio, + } as React.CSSProperties + } > {clickOverlay && overlaySize.width > 0 && (