Merge pull request #584 from webadderallorg/fix/windows-recorder-hud-passthrough

fix(recording): compact HUD fallback on Windows 10
This commit is contained in:
Phạm Thị Minh Hồng
2026-05-24 22:08:36 +07:00
committed by GitHub
4 changed files with 256 additions and 31 deletions
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
} from "./hudOverlayBounds";
describe("getHudOverlayWindowBounds", () => {
const workArea = {
x: 120,
y: 40,
width: 1920,
height: 1040,
};
it("uses the full work area when mouse passthrough is supported", () => {
expect(getHudOverlayWindowBounds(workArea, true)).toEqual(workArea);
});
it("uses a bottom-centered compact fallback when mouse passthrough is unavailable", () => {
expect(getHudOverlayWindowBounds(workArea, false)).toEqual({
x: 650,
y: 920,
width: 860,
height: 160,
});
});
it("expands the non-passthrough fallback for HUD menus and hover interaction", () => {
expect(getHudOverlayWindowBounds(workArea, false, true)).toEqual({
x: 650,
y: 540,
width: 860,
height: 540,
});
});
it("keeps the compact fallback inside small displays", () => {
expect(
getHudOverlayWindowBounds(
{
x: -100,
y: 20,
width: 640,
height: 420,
},
false,
),
).toEqual({
x: -100,
y: 280,
width: 640,
height: 160,
});
});
it("fits the expanded fallback inside small displays", () => {
expect(
getHudOverlayWindowBounds(
{
x: -100,
y: 20,
width: 640,
height: 420,
},
false,
true,
),
).toEqual({
x: -100,
y: 20,
width: 640,
height: 420,
});
});
});
describe("resizeHudOverlayFallbackBounds", () => {
const workArea = {
x: 0,
y: 0,
width: 1920,
height: 1080,
};
it("preserves the dragged bottom edge when expanding", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 420,
y: 700,
width: 860,
height: 160,
},
true,
),
).toEqual({
x: 420,
y: 320,
width: 860,
height: 540,
});
});
it("preserves the dragged bottom edge when compacting", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 420,
y: 320,
width: 860,
height: 540,
},
false,
),
).toEqual({
x: 420,
y: 700,
width: 860,
height: 160,
});
});
it("keeps resized fallback bounds inside the display work area", () => {
expect(
resizeHudOverlayFallbackBounds(
workArea,
{
x: 1500,
y: 900,
width: 860,
height: 160,
},
true,
),
).toEqual({
x: 1060,
y: 520,
width: 860,
height: 540,
});
});
});
+55
View File
@@ -0,0 +1,55 @@
export interface HudOverlayWorkArea {
x: number;
y: number;
width: number;
height: number;
}
const NON_PASSTHROUGH_HUD_WIDTH_DIP = 860;
const NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP = 160;
const NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP = 540;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function getHudOverlayWindowBounds(
workArea: HudOverlayWorkArea,
mousePassthroughSupported: boolean,
fallbackExpanded = false,
): HudOverlayWorkArea {
if (mousePassthroughSupported) {
return { ...workArea };
}
const width = Math.min(workArea.width, NON_PASSTHROUGH_HUD_WIDTH_DIP);
const height = Math.min(
workArea.height,
fallbackExpanded
? NON_PASSTHROUGH_HUD_EXPANDED_HEIGHT_DIP
: NON_PASSTHROUGH_HUD_COMPACT_HEIGHT_DIP,
);
return {
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + workArea.height - height),
width,
height,
};
}
export function resizeHudOverlayFallbackBounds(
workArea: HudOverlayWorkArea,
currentBounds: HudOverlayWorkArea,
fallbackExpanded: boolean,
): HudOverlayWorkArea {
const nextBounds = getHudOverlayWindowBounds(workArea, false, fallbackExpanded);
const maxX = workArea.x + workArea.width - nextBounds.width;
const maxY = workArea.y + workArea.height - nextBounds.height;
return {
...nextBounds,
x: clamp(currentBounds.x, workArea.x, maxX),
y: clamp(currentBounds.y + currentBounds.height - nextBounds.height, workArea.y, maxY),
};
}
+35 -6
View File
@@ -5,6 +5,10 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, ipcMain } from "electron";
import { USER_DATA_PATH } from "./appPaths";
import {
getHudOverlayWindowBounds,
resizeHudOverlayFallbackBounds,
} from "./hudOverlayBounds";
import { getPackagedRendererBaseUrl } from "./rendererServer";
const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url));
@@ -23,6 +27,7 @@ const WINDOW_ICON_PATH = path.join(
let hudOverlayWindow: BrowserWindow | null = null;
let hudOverlayHiddenFromCapture = true;
let hudOverlayCaptureProtectionLoaded = false;
let hudOverlayFallbackExpanded = false;
let countdownWindow: BrowserWindow | null = null;
let updateToastWindow: BrowserWindow | null = null;
@@ -183,12 +188,11 @@ function getHudOverlayDisplay() {
function getHudOverlayBounds() {
const { workArea } = getHudOverlayDisplay();
return {
x: workArea.x,
y: workArea.y,
width: workArea.width,
height: workArea.height,
};
return getHudOverlayWindowBounds(
workArea,
isHudOverlayMousePassthroughSupported(),
hudOverlayFallbackExpanded,
);
}
function applyHudOverlayBounds() {
@@ -242,9 +246,33 @@ function positionUpdateToastWindow() {
updateToastWindow.moveTop();
}
function setHudOverlayFallbackExpanded(expanded: boolean) {
hudOverlayFallbackExpanded = expanded;
if (
!hudOverlayWindow ||
hudOverlayWindow.isDestroyed() ||
isHudOverlayMousePassthroughSupported()
) {
return;
}
const { workArea } = getHudOverlayDisplay();
const nextBounds = resizeHudOverlayFallbackBounds(
workArea,
hudOverlayWindow.getBounds(),
expanded,
);
hudOverlayWindow.setBounds(nextBounds, false);
positionUpdateToastWindow();
if (hudOverlayWindow.isVisible()) {
hudOverlayWindow.moveTop();
}
}
ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => {
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
if (!isHudOverlayMousePassthroughSupported()) {
setHudOverlayFallbackExpanded(!ignore);
hudOverlayWindow.setIgnoreMouseEvents(false);
return;
}
@@ -358,6 +386,7 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean)
export function createHudOverlayWindow(): BrowserWindow {
loadHudOverlayCaptureProtectionSetting();
hudOverlayFallbackExpanded = false;
const initialBounds = getHudOverlayBounds();
let hasShownHudWindow = false;
+21 -25
View File
@@ -1,46 +1,45 @@
import {
ArrowClockwiseIcon,
CaretUpIcon,
DotsThreeVerticalIcon,
MicrophoneIcon,
MicrophoneSlashIcon,
MinusIcon,
MonitorIcon,
DotsThreeVerticalIcon,
TimerIcon,
VideoCameraIcon,
VideoCameraSlashIcon,
XIcon,
ArrowClockwiseIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef } from "react";
import { RxDragHandleDots2 } from "react-icons/rx";
import { Separator } from "@/components/ui/separator";
import { useScopedT } from "../../contexts/I18nContext";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import { Button } from "../ui/button";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import {
canToggleFloatingWebcamPreview,
} from "./floatingWebcamPreview";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import styles from "./LaunchWindow.module.css";
import { CountdownPopover } from "./popovers/CountdownPopover";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { ProjectPopover } from "./popovers/ProjectPopover";
import { SourcePopover } from "./popovers/SourcePopover";
import { WebcamPopover } from "./popovers/WebcamPopover";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import { MarqueeText } from "./SourceSelector";
import styles from "./LaunchWindow.module.css";
import { Separator } from "@/components/ui/separator";
import { Button } from "../ui/button";
import { RecordingControls } from "./RecordingControls";
import { useEffect, useRef } from "react";
import { MarqueeText } from "./SourceSelector";
const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;
@@ -427,6 +426,8 @@ function LaunchWindowContent() {
);
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
const useNativeHudBarDrag =
platform === "linux" || hudOverlayMousePassthroughSupported === false;
return (
<HudInteractionContext.Provider value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}>
@@ -456,16 +457,11 @@ function LaunchWindowContent() {
className={`${styles.bar} launch-theme mb-2`}
>
<div
// On Linux (especially Wayland) the compositor owns window
// placement, so BrowserWindow.setBounds() is silently ignored.
// Fall back to a native OS drag via -webkit-app-region on the
// handle. We still need JS pointer handlers in webcam-preview
// mode (which translates via CSS inside the window), so only
// mark the handle as a native drag region for the IPC path.
// Linux compositors and non-passthrough Windows fallback windows
// need native window dragging; the JS drag path only translates
// content inside the HUD window.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
platform === "linux" && !showRecordingWebcamPreview
? styles.electronDrag
: ""
useNativeHudBarDrag ? styles.electronDrag : ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}