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.
This commit is contained in:
Josh Hawkins
2026-11-13 09:54:54 -06:00
parent bb04e6d49d
commit c1ecdd80cc
2 changed files with 41 additions and 4 deletions
+31
View File
@@ -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<HTMLCanvasElement>();
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({
</div>
)}
{player}
{switchFrame && !liveReady && (
<div
className="absolute inset-0"
ref={(node) => node?.replaceChildren(switchFrame)}
/>
)}
{cameraEnabled &&
!offline &&
(!showStillWithoutActivity || isReEnabling) &&
@@ -582,3 +597,19 @@ export default function LivePlayer({
</div>
);
}
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;
}
+10 -4
View File
@@ -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 && (
<div
@@ -914,7 +917,10 @@ export default function LiveCameraView({
)}
<LivePlayer
key={camera.name}
className={fullscreen ? "rounded-none" : ""}
className={cn(
"aspect-[var(--camera-aspect)]",
fullscreen && "rounded-none",
)}
windowVisible
showStillWithoutActivity={false}
alwaysShowCameraName={false}