Skip to content

Commit cc0832c

Browse files
mcuelenaereclaude
andcommitted
feat(ui): pause video stream when tab is hidden via Page Visibility API
Mirrors the new server-side pauseVideo/resumeVideo JSON-RPC methods in the web frontend. When the user switches tabs or minimizes the browser, the encoder feed stops and outbound RTP drops to keepalive levels until the tab regains visibility. State is synced on mount so a reconnect into an already-hidden tab pauses immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a31c82a commit cc0832c

1 file changed

Lines changed: 40 additions & 24 deletions

File tree

ui/src/components/WebRTCVideo.tsx

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cx } from "@/cva.config";
55
import { isWindows } from "@/utils";
66
import useKeyboard from "@hooks/useKeyboard";
77
import useMouse from "@hooks/useMouse";
8+
import { useJsonRpc } from "@hooks/useJsonRpc";
89
import { useRTCStore, useSettingsStore, useUiStore, useVideoStore } from "@hooks/stores";
910
import VirtualKeyboard from "@components/VirtualKeyboard";
1011
import Actionbar from "@components/ActionBar";
@@ -41,6 +42,7 @@ export default function WebRTCVideo({
4142

4243
// Store hooks
4344
const settings = useSettingsStore();
45+
const { send } = useJsonRpc();
4446
const { handleKeyPress, resetKeyboardState } = useKeyboard();
4547
const {
4648
getRelMouseMoveHandler,
@@ -486,6 +488,30 @@ export default function WebRTCVideo({
486488
[keyDownHandler, keyUpHandler, resetKeyboardState],
487489
);
488490

491+
// Pause/resume the server-side video feed when the tab is hidden so we
492+
// don't burn WAN bandwidth decoding-then-discarding frames the user
493+
// can't see. The encoder is restarted on resume so the first frame is
494+
// an IDR and decode is artifact-free.
495+
useEffect(
496+
function pauseVideoOnTabHidden() {
497+
const sync = () => {
498+
send(document.hidden ? "pauseVideo" : "resumeVideo", {});
499+
};
500+
501+
// Sync once on mount in case the tab is already hidden when we
502+
// (re)connect, then track every visibility change.
503+
sync();
504+
505+
const abortController = new AbortController();
506+
document.addEventListener("visibilitychange", sync, {
507+
signal: abortController.signal,
508+
});
509+
510+
return () => abortController.abort();
511+
},
512+
[send],
513+
);
514+
489515
// Setup Video Event Listeners
490516
useEffect(
491517
function setupVideoEventListeners() {
@@ -606,13 +632,8 @@ export default function WebRTCVideo({
606632
<div className="grid h-full w-full grid-rows-(--grid-layout)">
607633
<div className="flex min-h-[39.5px] flex-col">
608634
<div className="flex flex-col">
609-
<fieldset
610-
disabled={peerConnection?.connectionState !== "connected"}
611-
className="contents"
612-
>
613-
<Actionbar
614-
requestFullscreen={requestFullscreen}
615-
/>
635+
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
636+
<Actionbar requestFullscreen={requestFullscreen} />
616637
<MacroBar />
617638
</fieldset>
618639
</div>
@@ -634,9 +655,7 @@ export default function WebRTCVideo({
634655
<div className="grid grow grid-rows-(--grid-bodyFooter) overflow-hidden">
635656
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
636657
<PointerLockBar show={showPointerLockBar} />
637-
<div
638-
className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"
639-
>
658+
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
640659
<div
641660
ref={fullscreenContainerRef}
642661
className="relative flex h-full w-full items-center justify-center"
@@ -652,20 +671,17 @@ export default function WebRTCVideo({
652671
disablePictureInPicture
653672
controlsList="nofullscreen"
654673
style={videoStyle}
655-
className={cx(
656-
"h-full w-full object-contain transition-all duration-1000",
657-
{
658-
"cursor-none": settings.isCursorHidden,
659-
"pointer-events-none": isOcrMode,
660-
"opacity-0!":
661-
isVideoLoading ||
662-
hdmiError ||
663-
hasConnectionIssues ||
664-
peerConnectionState !== "connected",
665-
"opacity-60!": showPointerLockBar,
666-
"animate-slideUpFade": isPlaying,
667-
},
668-
)}
674+
className={cx("h-full w-full object-contain transition-all duration-1000", {
675+
"cursor-none": settings.isCursorHidden,
676+
"pointer-events-none": isOcrMode,
677+
"opacity-0!":
678+
isVideoLoading ||
679+
hdmiError ||
680+
hasConnectionIssues ||
681+
peerConnectionState !== "connected",
682+
"opacity-60!": showPointerLockBar,
683+
"animate-slideUpFade": isPlaying,
684+
})}
669685
/>
670686
<OcrOverlay />
671687
{peerConnection?.connectionState == "connected" && !hasConnectionIssues && (

0 commit comments

Comments
 (0)