Files
Recordly/src/components/launch/LaunchWindow.tsx
T
webadderall cd8a2fe827 Merge pull request #1200 from AntimonyBullets/feature/ignore-hud-auto-zooms-v2
Ignore recording HUD and webcam overlay clicks when generating auto-zooms
2026-11-12 06:49:47 +11:00

562 lines
17 KiB
TypeScript

import { Spinner } from "@heroui/react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
House,
MicrophoneIcon,
MicrophoneSlashIcon,
MinusIcon,
MonitorIcon,
SpeakerHighIcon,
VideoCameraIcon,
VideoCameraSlashIcon,
} from "@/components/ui/icons";
import { toast } from "@/components/ui/toast";
import { useScopedT } from "../../contexts/I18nContext";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { Button } from "../ui/button";
import { CountdownDelayIcon } from "./CountdownDelayIcon";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import { FloatingWebcamPreviewView } from "./FloatingWebcamPreviewView";
import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useHudStateTransition } from "./hooks/useHudStateTransition";
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 { MarqueeText } from "./MarqueeText";
import { CountdownPopover } from "./popovers/CountdownPopover";
import {
LaunchPopoverCoordinatorProvider,
useLaunchPopoverCoordinator,
} from "./popovers/LaunchPopoverCoordinator";
import { MicPopover } from "./popovers/MicPopover";
import { WebcamPopover } from "./popovers/WebcamPopover";
import { RecordButton } from "./RecordButton";
import { RecordingControls } from "./RecordingControls";
export function LaunchWindow() {
return (
<LaunchPopoverCoordinatorProvider>
<LaunchWindowContent />
</LaunchPopoverCoordinatorProvider>
);
}
function LaunchWindowContent() {
const t = useScopedT("launch");
const captureT = useScopedT("capture");
const { openId } = useLaunchPopoverCoordinator();
const {
recording,
paused,
finalizing,
restarting,
countdownActive,
toggleRecording,
pauseRecording,
resumeRecording,
cancelRecording,
cancelRecordingStart,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
countdownDelay,
setCountdownDelay,
preparePermissions,
} = useScreenRecorder();
const { elapsed, formatTime } = useRecordingTimer(recording, paused);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
const { selectedSource, hasSelectedSource, syncSelectedSource } = useLaunchWindowActions();
const showWebcamControls = webcamEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(
microphoneEnabled || openId === "mic",
microphoneDeviceId,
);
const {
devices: videoDevices,
selectedDeviceId: selectedVideoDeviceId,
setSelectedDeviceId: setSelectedVideoDeviceId,
} = useVideoDevices(webcamEnabled || openId === "webcam", webcamDeviceId);
const { hudOverlayMousePassthroughSupported, platform } =
useLaunchWindowSystemState(preparePermissions);
useEffect(() => {
if (!selectedDeviceId) {
return;
}
setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId);
}, [selectedDeviceId, setMicrophoneDeviceId]);
useEffect(() => {
if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") {
setWebcamDeviceId(selectedVideoDeviceId);
}
}, [selectedVideoDeviceId, setWebcamDeviceId]);
const webcamPreview = useWebcamPreviewOverlay({
webcamEnabled,
webcamDeviceId,
showWebcamControls,
webcamPopoverOpen: openId === "webcam",
hudOverlayMousePassthroughSupported,
hudBarRef,
});
const {
webcamPreviewRoundness,
webcamPreviewShape,
showFloatingWebcamPreview,
setShowFloatingWebcamPreview,
showRecordingWebcamPreview,
recordingWebcamPreviewContainerRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
setWebcamPreviewNode,
} = webcamPreview;
useEffect(() => {
window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(showRecordingWebcamPreview);
}, [showRecordingWebcamPreview]);
useEffect(() => {
return () => {
window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(false);
};
}, []);
const {
recordingHudOffset,
isHudDragging,
hudBarTransformRef,
isHudDraggingRef,
handleHudBarPointerDown,
handleHudBarPointerMove,
handleHudBarPointerUp,
} = useHudBarDrag({
hudContentRef,
hudBarRef,
recordingWebcamPreviewContainerRef,
docked: showRecordingWebcamPreview,
});
useEffect(() => {
const reportInteractiveBounds = () => {
const elements = [hudBarRef.current, recordingWebcamPreviewContainerRef.current].filter(
(element): element is HTMLDivElement => element !== null,
);
if (elements.length === 0) {
window.electronAPI?.hudOverlaySetInteractiveBounds?.(null);
return;
}
window.electronAPI?.hudOverlaySetInteractiveBounds?.(
elements.map((element) => {
const rect = element.getBoundingClientRect();
return {
left: window.screenX + rect.left,
top: window.screenY + rect.top,
right: window.screenX + rect.right,
bottom: window.screenY + rect.bottom,
};
}),
);
};
reportInteractiveBounds();
const resizeObserver = new ResizeObserver(reportInteractiveBounds);
if (hudBarRef.current) resizeObserver.observe(hudBarRef.current);
if (recordingWebcamPreviewContainerRef.current) {
resizeObserver.observe(recordingWebcamPreviewContainerRef.current);
}
window.addEventListener("mousemove", reportInteractiveBounds);
window.addEventListener("resize", reportInteractiveBounds);
return () => {
resizeObserver.disconnect();
window.removeEventListener("mousemove", reportInteractiveBounds);
window.removeEventListener("resize", reportInteractiveBounds);
window.electronAPI?.hudOverlaySetInteractiveBounds?.(null);
};
}, [
recordingHudOffset,
recordingWebcamPreviewContainerRef,
showRecordingWebcamPreview,
webcamPreview.webcamPreviewOffset,
]);
const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } =
useLaunchHudInteractionState({
openId,
platform,
recordingState: `${recording}:${finalizing}`,
isHudDraggingRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
});
const pickerPending = useRef(false);
const [isPickerPending, setIsPickerPending] = useState(false);
const [sourceReady, setSourceReady] = useState(false);
const handlePickOnScreen = useCallback(async () => {
if (pickerPending.current || recording || finalizing) return;
pickerPending.current = true;
setIsPickerPending(true);
beginInteractiveHudAction();
try {
if (!(await cancelRecordingStart())) return;
if (!(await preparePermissions())) return;
const result = await window.electronAPI.pickCaptureTarget();
if (result.success && result.source) syncSelectedSource(result.source);
if (result.message && !result.canceled) {
if (result.success) toast.info(result.message);
else toast.error(result.message);
}
} catch {
toast.error(captureT("pickerError"));
} finally {
pickerPending.current = false;
setIsPickerPending(false);
// The native picker temporarily overrides passthrough outside this hook.
beginInteractiveHudAction();
}
}, [
captureT,
recording,
cancelRecordingStart,
preparePermissions,
finalizing,
beginInteractiveHudAction,
syncSelectedSource,
]);
useEffect(() => {
let mounted = true;
let selectionEventSeen = false;
void window.electronAPI
.getSelectedSource()
.then((source) => {
if (mounted) setSourceReady(true);
if (mounted && !selectionEventSeen) {
syncSelectedSource(source);
}
})
.catch(() => {
if (mounted) {
setSourceReady(true);
toast.error(captureT("sourceLoadError"));
}
});
const cleanup = window.electronAPI.onSelectedSourceChanged((source) => {
selectionEventSeen = true;
if (mounted) syncSelectedSource(source);
});
return () => {
mounted = false;
cleanup?.();
};
}, [syncSelectedSource, captureT]);
const cancelPendingStart = useCallback(async () => {
try {
return await cancelRecordingStart();
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
return false;
}
}, [cancelRecordingStart]);
const openHome = async () => {
if (!(await cancelPendingStart())) return;
localStorage.setItem("recordly.open-dashboard", String(Date.now()));
void window.electronAPI.showProjectDashboard();
};
const homeButton = (
<Button
variant="ghost"
size="icon"
iconSize="lg"
aria-label={t("recording.home")}
title={t("recording.home")}
onClick={openHome}
>
<House weight="fill" className="size-5" />
</Button>
);
const recordingControls = (
<RecordingControls
busy={restarting}
paused={paused}
elapsed={elapsed}
onPauseResume={paused ? resumeRecording : pauseRecording}
onStopRecording={toggleRecording}
onCancelRecording={cancelRecording}
formatTime={formatTime}
/>
);
const idleControls = (
<div
role="group"
aria-label={captureT("recordingSetup")}
className={styles.recordingControls}
>
<RecordButton
countdownActive={countdownActive}
countdownDelay={countdownDelay}
disabled={isPickerPending || !sourceReady}
onRecord={hasSelectedSource ? toggleRecording : () => void handlePickOnScreen()}
onCancel={() => void cancelPendingStart()}
/>
<Button
variant="ghost"
size="lg"
className={`${styles.electronNoDrag} ${styles.sourceControl} group gap-2`}
title={selectedSource}
aria-label={captureT("chooseSource")}
disabled={isPickerPending || !sourceReady || recording || finalizing}
onClick={() => void handlePickOnScreen()}
>
<MonitorIcon size={18} className="size-5 shrink-0" />
<div className="min-w-0 overflow-hidden">
{hasSelectedSource ? (
<MarqueeText text={selectedSource} />
) : (
<span>{captureT("pickSource")}</span>
)}
</div>
</Button>
<span aria-hidden="true" className={styles.sourceDivider} />
<MicPopover
disabled={recording || countdownActive}
systemAudioEnabled={systemAudioEnabled}
onToggleSystemAudio={() => setSystemAudioEnabled(!systemAudioEnabled)}
microphoneEnabled={microphoneEnabled}
onDisableMicrophone={() => setMicrophoneEnabled(false)}
devices={devices}
microphoneDeviceId={microphoneDeviceId}
selectedDeviceId={selectedDeviceId}
onSelectDevice={(deviceId) => {
setMicrophoneEnabled(true);
setSelectedDeviceId(deviceId);
setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId);
}}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
disabled={countdownActive}
aria-label={t("recording.audioSettings", "Audio settings")}
className={microphoneEnabled || systemAudioEnabled ? "text-accent" : ""}
>
{microphoneEnabled ? (
<MicrophoneIcon
weight={microphoneEnabled ? "fill" : "regular"}
className="size-5"
size={18}
/>
) : systemAudioEnabled ? (
<SpeakerHighIcon className="size-5" />
) : (
<MicrophoneSlashIcon className="size-5" />
)}
</Button>
}
/>
<WebcamPopover
disabled={recording || countdownActive}
webcamEnabled={webcamEnabled}
onDisableWebcam={() => setWebcamEnabled(false)}
canToggleFloatingPreview={canToggleFloatingWebcamPreview(
hudOverlayMousePassthroughSupported,
)}
showFloatingWebcamPreview={showFloatingWebcamPreview}
onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)}
showWebcamControls={showWebcamControls && !showRecordingWebcamPreview}
roundness={webcamPreviewRoundness}
shape={webcamPreviewShape}
setWebcamPreviewNode={setWebcamPreviewNode}
videoDevices={videoDevices}
webcamDeviceId={webcamDeviceId}
selectedVideoDeviceId={selectedVideoDeviceId}
onSelectVideoDevice={(deviceId) => {
setWebcamEnabled(true);
setSelectedVideoDeviceId(deviceId);
setWebcamDeviceId(deviceId);
}}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
disabled={countdownActive}
aria-label={t("recording.cameraSettings", "Camera settings")}
className={webcamEnabled ? "text-accent" : ""}
>
{webcamEnabled ? (
<VideoCameraIcon
weight={webcamEnabled ? "fill" : "regular"}
className="size-5"
size={18}
/>
) : (
<VideoCameraSlashIcon className="size-5" />
)}
</Button>
}
/>
<CountdownPopover
countdownDelay={countdownDelay}
onSelectDelay={setCountdownDelay}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
disabled={countdownActive}
title={`${t("recording.countdownDelay")}: ${countdownDelay === 0 ? t("recording.noDelay") : `${countdownDelay}s`}`}
className={countdownDelay > 0 ? "text-accent" : ""}
>
<CountdownDelayIcon seconds={countdownDelay} className="size-5" />
</Button>
}
/>
<span aria-hidden="true" className={styles.sourceDivider} />
{homeButton}
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={() => window.electronAPI?.hudOverlayHide?.()}
disabled={countdownActive}
title={t("recording.hideHud")}
>
<MinusIcon className="size-5" />
</Button>
</div>
);
const useNativeHudBarDrag =
platform === "linux" || hudOverlayMousePassthroughSupported === false;
const finishHudTransition = useHudStateTransition(
hudBarRef,
finalizing && !restarting ? "finalizing" : recording || restarting ? "recording" : "idle",
showRecordingWebcamPreview,
);
const hudBar = (
<div
ref={hudBarTransformRef}
className={showRecordingWebcamPreview ? styles.webcamHudDock : undefined}
style={{
transform: showRecordingWebcamPreview
? undefined
: `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`,
}}
>
<div
ref={hudBarRef}
data-hud-surface
onPointerDownCapture={finishHudTransition}
data-hud-dragging={isHudDragging}
className={`${styles.bar} ${styles.recordingBar} ${useNativeHudBarDrag ? styles.electronDrag : ""} launch-theme pointer-events-auto`}
data-recording-state={
finalizing
? "finalizing"
: recording
? paused
? "paused"
: "recording"
: "idle"
}
onPointerDown={
showRecordingWebcamPreview || useNativeHudBarDrag
? undefined
: handleHudBarPointerDown
}
onPointerMove={showRecordingWebcamPreview ? undefined : handleHudBarPointerMove}
onPointerUp={showRecordingWebcamPreview ? undefined : handleHudBarPointerUp}
onPointerCancel={showRecordingWebcamPreview ? undefined : handleHudBarPointerUp}
onLostPointerCapture={
showRecordingWebcamPreview ? undefined : handleHudBarPointerUp
}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
>
<div className={styles.barStateViewport}>
<div className={styles.barState}>
{finalizing && !restarting ? (
<div
role="status"
className={`${styles.recordingControls} ${styles.recordingFinalizing} gap-2 text-sm text-muted-foreground`}
>
<Spinner size="sm" />
{captureT("finishingRecording", "Finishing recording…")}
</div>
) : recording || restarting ? (
recordingControls
) : (
idleControls
)}
</div>
</div>
</div>
</div>
);
return (
<HudInteractionContext.Provider
value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}
>
<div
className="w-full flex justify-center bg-transparent overflow-visible items-end pb-6 pointer-events-none"
style={{ height: "100vh" }}
>
<div
ref={hudContentRef}
className="flex items-center overflow-visible flex-col-reverse pointer-events-none"
>
<div className="flex flex-col items-center pointer-events-none p-2">
{!showRecordingWebcamPreview && hudBar}
{showRecordingWebcamPreview && (
<FloatingWebcamPreviewView
preview={webcamPreview}
hudBar={hudBar}
finishHudTransition={finishHudTransition}
handleHudMouseEnter={handleHudMouseEnter}
handleHudMouseLeave={handleHudMouseLeave}
title={t("recording.webcam")}
/>
)}
</div>
</div>
</div>
</HudInteractionContext.Provider>
);
}