feat(launch): add draggable floating webcam preview overlay

Show a live webcam preview as soon as webcam is enabled, keep it visible as a larger floating overlay, and allow drag repositioning across the HUD overlay area for expression monitoring while recording.

Made-with: Cursor
This commit is contained in:
Long
2026-04-03 08:42:50 +07:00
parent 4d29baaae3
commit 68aa2a9686
2 changed files with 159 additions and 29 deletions
@@ -321,6 +321,40 @@
animation: blink 1.2s ease-in-out infinite;
}
.recordingWebcamPreview {
position: fixed;
right: 32px;
bottom: 120px;
height: 288px;
width: 288px;
overflow: hidden;
border-radius: 24px;
background: rgba(22, 22, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 10px 24px rgba(0, 0, 0, 0.28),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
cursor: grab;
touch-action: none;
user-select: none;
will-change: transform;
z-index: 120;
}
.recordingWebcamPreview:active {
cursor: grabbing;
}
.recordingWebcamPreviewVideo {
display: block;
height: 100%;
width: 100%;
object-fit: cover;
border-radius: inherit;
overflow: hidden;
transform: translateZ(0);
}
@keyframes blink {
0%,
100% {
+125 -29
View File
@@ -187,6 +187,7 @@ export function LaunchWindow() {
const [sources, setSources] = useState<DesktopSource[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(false);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState({ x: 0, y: 0 });
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateStatus, setUpdateStatus] = useState<{
@@ -205,11 +206,21 @@ export function LaunchWindow() {
const hudBarRef = useRef<HTMLDivElement>(null);
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const webcamPreviewDragStartRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
} | null>(null);
const isDraggingRef = useRef(false);
const micDropdownOpen = activeDropdown === "mic";
const webcamDropdownOpen = activeDropdown === "webcam";
const showWebcamControls = webcamEnabled && !recording;
const showWebcamControls = webcamEnabled;
const showRecordingWebcamPreview = webcamEnabled;
const shouldStreamWebcamPreview = webcamEnabled;
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(
microphoneEnabled || micDropdownOpen,
microphoneDeviceId,
@@ -236,12 +247,65 @@ export function LaunchWindow() {
}
}, [selectedVideoDeviceId, setWebcamDeviceId]);
useEffect(() => {
if (!webcamEnabled) {
setWebcamPreviewOffset({ x: 0, y: 0 });
}
}, [webcamEnabled]);
const handleWebcamPreviewPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
isDraggingRef.current = true;
webcamPreviewDragStartRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: webcamPreviewOffset.x,
originY: webcamPreviewOffset.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handleWebcamPreviewPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = webcamPreviewDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
setWebcamPreviewOffset({
x: dragState.originX + deltaX,
y: dragState.originY + deltaY,
});
};
const handleWebcamPreviewPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = webcamPreviewDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
webcamPreviewDragStartRef.current = null;
isDraggingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
useEffect(() => {
let mounted = true;
let previewStream: MediaStream | null = null;
const startPreview = async () => {
if (!showWebcamControls || !webcamPreviewRef.current) {
if (
!shouldStreamWebcamPreview ||
(!webcamPreviewRef.current && !recordingWebcamPreviewRef.current)
) {
return;
}
@@ -262,18 +326,22 @@ export function LaunchWindow() {
audio: false,
});
if (!mounted || !webcamPreviewRef.current) {
if (!mounted) {
previewStream.getTracks().forEach((track) => track.stop());
return;
}
webcamPreviewRef.current.srcObject = previewStream;
const playPromise = webcamPreviewRef.current.play();
if (playPromise) {
playPromise.catch(() => {
// Ignore autoplay interruptions while the preview element mounts.
[webcamPreviewRef.current, recordingWebcamPreviewRef.current]
.filter((node): node is HTMLVideoElement => Boolean(node))
.forEach((videoElement) => {
videoElement.srcObject = previewStream;
const playPromise = videoElement.play();
if (playPromise) {
playPromise.catch(() => {
// Ignore autoplay interruptions while the preview element mounts.
});
}
});
}
} catch (error) {
console.warn("Failed to start live webcam preview:", error);
}
@@ -283,13 +351,15 @@ export function LaunchWindow() {
return () => {
mounted = false;
if (webcamPreviewRef.current) {
webcamPreviewRef.current.pause();
webcamPreviewRef.current.srcObject = null;
}
[webcamPreviewRef.current, recordingWebcamPreviewRef.current]
.filter((node): node is HTMLVideoElement => Boolean(node))
.forEach((videoElement) => {
videoElement.pause();
videoElement.srcObject = null;
});
previewStream?.getTracks().forEach((track) => track.stop());
};
}, [showWebcamControls, webcamDeviceId]);
}, [shouldStreamWebcamPreview, webcamDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
@@ -452,13 +522,13 @@ export function LaunchWindow() {
}, []);
useEffect(() => {
const expanded = activeDropdown !== "none" || projectBrowserOpen;
const expanded = activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview;
window.electronAPI.setHudOverlayExpanded(expanded);
return () => {
window.electronAPI.setHudOverlayExpanded(false);
};
}, [activeDropdown, projectBrowserOpen]);
}, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]);
useEffect(() => {
const hudContent = hudContentRef.current;
@@ -470,21 +540,26 @@ export function LaunchWindow() {
let frameId = 0;
const reportHudSize = () => {
frameId = 0;
const measuredWidth = Math.ceil(
Math.max(
hudBar.getBoundingClientRect().width,
hudBar.scrollWidth,
hudContent.getBoundingClientRect().width,
hudContent.scrollWidth,
) + 24,
);
const measuredHeight = Math.ceil(
Math.max(hudContent.getBoundingClientRect().height, hudContent.scrollHeight) + 24,
);
const hasFloatingWebcamPreview = showRecordingWebcamPreview;
const measuredWidth = hasFloatingWebcamPreview
? 100000
: Math.ceil(
Math.max(
hudBar.getBoundingClientRect().width,
hudBar.scrollWidth,
hudContent.getBoundingClientRect().width,
hudContent.scrollWidth,
) + 24,
);
const measuredHeight = hasFloatingWebcamPreview
? 100000
: Math.ceil(
Math.max(hudContent.getBoundingClientRect().height, hudContent.scrollHeight) + 24,
);
window.electronAPI.setHudOverlayCompactWidth(measuredWidth);
window.electronAPI.setHudOverlayMeasuredHeight(
measuredHeight,
activeDropdown !== "none" || projectBrowserOpen,
hasFloatingWebcamPreview || activeDropdown !== "none" || projectBrowserOpen,
);
};
@@ -509,7 +584,7 @@ export function LaunchWindow() {
cancelAnimationFrame(frameId);
}
};
}, [activeDropdown, projectBrowserOpen]);
}, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
@@ -1223,6 +1298,27 @@ export function LaunchWindow() {
</AnimatePresence>
</div>
</motion.div>
{showRecordingWebcamPreview && (
<div
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag}`}
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
}}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={recordingWebcamPreviewRef}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
</div>
</div>