From 68aa2a96869497ad7db09fdfbae2ec48c617b73f Mon Sep 17 00:00:00 2001 From: Long Date: Fri, 3 Apr 2026 08:42:50 +0700 Subject: [PATCH] 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 --- src/components/launch/LaunchWindow.module.css | 34 ++++ src/components/launch/LaunchWindow.tsx | 154 ++++++++++++++---- 2 files changed, 159 insertions(+), 29 deletions(-) diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 9bd61c6f..230f24b3 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -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% { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 46bef471..852c520e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -187,6 +187,7 @@ export function LaunchWindow() { const [sources, setSources] = useState([]); const [sourcesLoading, setSourcesLoading] = useState(false); const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + const [webcamPreviewOffset, setWebcamPreviewOffset] = useState({ x: 0, y: 0 }); const [platform, setPlatform] = useState(null); const [appVersion, setAppVersion] = useState(null); const [updateStatus, setUpdateStatus] = useState<{ @@ -205,11 +206,21 @@ export function LaunchWindow() { const hudBarRef = useRef(null); const moreButtonRef = useRef(null); const webcamPreviewRef = useRef(null); + const recordingWebcamPreviewRef = useRef(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) => { + 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) => { + 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) => { + 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() { + {showRecordingWebcamPreview && ( +
+
+ )}