diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 573aee8a..5fe8c6cf 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,7 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { + getSessionType: () => Promise; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e43f53c9..a5d60387 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -356,6 +356,11 @@ export function registerIpcHandlers( getSourceSelectorWindow: () => BrowserWindow | null, onRecordingStateChange?: (recording: boolean, sourceName: string) => void, ) { + ipcMain.handle("get-session-type", () => { + if (process.platform !== "linux") return "x11"; + return process.env.XDG_SESSION_TYPE || (process.env.WAYLAND_DISPLAY ? "wayland" : "x11"); + }); + ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); return sources.map((source) => ({ diff --git a/electron/main.ts b/electron/main.ts index 7e19d468..fbf69f41 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -25,6 +25,12 @@ if (process.platform === "darwin") { app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } +// Enable PipeWire screen capture on Linux (required for Wayland screen sharing) +if (process.platform === "linux") { + app.commandLine.appendSwitch("enable-features", "WebRTCPipeWireCapturer"); + app.commandLine.appendSwitch("ozone-platform-hint", "auto"); +} + export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { diff --git a/electron/preload.ts b/electron/preload.ts index 8f1836bd..aa30f813 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -12,6 +12,9 @@ contextBridge.exposeInMainWorld("electronAPI", { // ask main process for the correct base path (production vs dev) return await ipcRenderer.invoke("get-asset-base-path"); }, + getSessionType: async () => { + return await ipcRenderer.invoke("get-session-type"); + }, getSources: async (opts: Electron.SourcesOptions) => { return await ipcRenderer.invoke("get-sources", opts); }, diff --git a/electron/windows.ts b/electron/windows.ts index fb9a6553..8e7501bf 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -21,26 +21,26 @@ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); const { workArea } = primaryDisplay; - const windowWidth = 600; - const windowHeight = 160; + const windowWidth = 480; + const windowHeight = 420; - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); + const x = Math.floor(workArea.x + workArea.width - windowWidth - 20); + const y = Math.floor(workArea.y + workArea.height - windowHeight - 20); const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 160, - maxHeight: 160, + minWidth: 380, + minHeight: 320, + maxWidth: 640, + maxHeight: 560, x: x, y: y, frame: false, transparent: true, - resizable: false, + resizable: true, alwaysOnTop: true, - skipTaskbar: true, + skipTaskbar: false, hasShadow: false, show: !HEADLESS, webPreferences: { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 249dd77d..aa1d89d7 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,6 +1,6 @@ import { ChevronDown, Languages } from "lucide-react"; -import { useEffect, useState } from "react"; -import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; +import { useCallback, useEffect, useState } from "react"; +import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; @@ -20,16 +20,17 @@ import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { type Locale, SUPPORTED_LOCALES } from "@/i18n/config"; import { getLocaleName } from "@/i18n/loader"; -import { isMac as getIsMac } from "@/utils/platformUtils"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; +import { usePreviewStream } from "../../hooks/usePreviewStream"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; +import { LivePreview } from "./LivePreview"; const ICON_SIZE = 20; @@ -73,11 +74,6 @@ const windowBtnClasses = export function LaunchWindow() { const t = useScopedT("launch"); const { locale, setLocale } = useI18n(); - const [isMac, setIsMac] = useState(false); - - useEffect(() => { - getIsMac().then(setIsMac); - }, []); const { recording, @@ -99,6 +95,18 @@ export function LaunchWindow() { setWebcamDeviceId, } = useScreenRecorder(); + const { + streams, + previewActive, + startPreview, + stopPreview, + detachScreenStream, + detachWebcamStream, + } = usePreviewStream({ webcamEnabled }); + + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const showMicControls = microphoneEnabled && !recording; const showWebcamControls = webcamEnabled && !recording; @@ -165,13 +173,22 @@ export function LaunchWindow() { const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); + // Poll for source selection and start preview when source is picked useEffect(() => { + let prevSourceId: string | null = null; + const checkSelectedSource = async () => { if (window.electronAPI) { const source = await window.electronAPI.getSelectedSource(); if (source) { setSelectedSource(source.name); setHasSelectedSource(true); + + // Auto-start preview when source changes + if (source.id !== prevSourceId && !recording) { + prevSourceId = source.id; + startPreview(source.id); + } } else { setSelectedSource("Screen"); setHasSelectedSource(false); @@ -183,21 +200,52 @@ export function LaunchWindow() { const interval = setInterval(checkSelectedSource, 500); return () => clearInterval(interval); - }, []); + }, [recording, startPreview]); - const openSourceSelector = () => { + const openSourceSelector = useCallback(() => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); } - }; - - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); + }, []); - if (result.canceled) { - return; + const handleToggleRecording = useCallback(() => { + if (recording) { + toggleRecording(); + // Restart preview after recording stops + setTimeout(async () => { + const source = await window.electronAPI.getSelectedSource(); + if (source) { + startPreview(source.id); + } + }, 500); + } else if (hasSelectedSource && previewActive) { + // Detach streams from preview and hand them to recorder + const screenStream = detachScreenStream(); + const webcamStream = detachWebcamStream(); + if (screenStream) { + toggleRecording({ screenStream, webcamStream }); + } else { + toggleRecording(); + } + } else if (hasSelectedSource) { + toggleRecording(); + } else { + openSourceSelector(); } + }, [ + recording, + hasSelectedSource, + previewActive, + toggleRecording, + startPreview, + detachScreenStream, + detachWebcamStream, + openSourceSelector, + ]); + const openVideoFile = async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) return; if (result.success && result.path) { await window.electronAPI.setCurrentVideoPath(result.path); await window.electronAPI.switchToEditor(); @@ -211,12 +259,14 @@ export function LaunchWindow() { }; const sendHudOverlayHide = () => { - if (window.electronAPI && window.electronAPI.hudOverlayHide) { + if (window.electronAPI?.hudOverlayHide) { window.electronAPI.hudOverlayHide(); } }; + const sendHudOverlayClose = () => { - if (window.electronAPI && window.electronAPI.hudOverlayClose) { + stopPreview(); + if (window.electronAPI?.hudOverlayClose) { window.electronAPI.hudOverlayClose(); } }; @@ -228,309 +278,206 @@ export function LaunchWindow() { }; return ( -
- {/* Language switcher — top-left, beside traffic lights */} -
- - + + +
+ + {recording && ( +
+
+ + REC {formatTimePadded(elapsed)} + +
+ )} + +
+ + +
+
+ + {/* Live preview area */} +
+
- {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} - {(showMicControls || showWebcamControls) && ( + {/* Mic controls panel */} + {showMicControls && (
- {/* Mic selector */} - {showMicControls && ( -
setIsMicHovered(true)} - onMouseLeave={() => setIsMicHovered(false)} - onFocus={() => setIsMicFocused(true)} - onBlur={() => setIsMicFocused(false)} - style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }} +
+ { - setSelectedMicId(e.target.value); - setMicrophoneDeviceId(e.target.value); - }} - className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`} - > - {micDevices.map((device) => ( - - ))} - - {micExpanded && ( - - )} -
- -
- )} - - {/* Webcam selector */} - {showWebcamControls && ( -
setIsWebcamHovered(true)} - onMouseLeave={() => setIsWebcamHovered(false)} - onFocus={() => setIsWebcamFocused(true)} - onBlur={() => setIsWebcamFocused(false)} - style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }} - > -
- {!webcamExpanded && ( -
- {selectedCameraLabel} -
- )} - {webcamExpanded && - (isCameraDevicesLoading ? ( - - {t("webcam.searching")} - - ) : cameraDevicesError ? ( - - {t("webcam.unavailable")} - - ) : cameraDevices.length === 0 ? ( - - {t("webcam.noneFound")} - - ) : ( - <> - - - - ))} - {(!webcamExpanded || cameraDevices.length === 0) && ( - - )} -
-
- )} + {devices.map((device) => ( + + ))} + + +
+
)} - {/* HUD bar — fixed at bottom center, viewport-relative, never moves */} -
- {/* Drag handle */} -
- {getIcon("drag", "text-white/30")} -
- - {/* Source selector */} - - - {/* Audio controls group */} -
+ {/* Control bar */} +
+
+ {/* Source selector */} - - -
- - {/* Record/Stop group */} - - {recording && ( - + {/* Audio controls group */} +
- - )} - - {/* Restart recording */} - {recording && ( - - - )} - - {/* Cancel recording */} - {recording && ( - - - )} +
- {/* Open video file */} - + {/* Record/Stop */} - +
- {/* Open project */} - - - + {/* Restart recording */} + {recording && ( + + + + )} + - {/* Window controls */} -
- - + {/* Open video file */} + + + + )} + + {/* Open project */} + + +
diff --git a/src/components/launch/LivePreview.tsx b/src/components/launch/LivePreview.tsx new file mode 100644 index 00000000..6a446077 --- /dev/null +++ b/src/components/launch/LivePreview.tsx @@ -0,0 +1,201 @@ +import { useEffect, useRef } from "react"; +import type { PreviewStreams } from "@/hooks/usePreviewStream"; + +const PIP_SIZE_RATIO = 0.22; +const PIP_MARGIN = 12; +const PREVIEW_FPS_INTERVAL = 1000 / 30; + +interface LivePreviewProps { + streams: PreviewStreams | null; + className?: string; +} + +export function LivePreview({ streams, className }: LivePreviewProps) { + const canvasRef = useRef(null); + const screenVideoRef = useRef(null); + const webcamVideoRef = useRef(null); + const animFrameRef = useRef(0); + const lastDrawRef = useRef(0); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || !streams) return; + + const ctx = canvas.getContext("2d", { alpha: false }); + if (!ctx) return; + + // Create hidden video elements for decoding + const screenVideo = document.createElement("video"); + screenVideo.muted = true; + screenVideo.playsInline = true; + screenVideo.srcObject = streams.screen; + screenVideo.play().catch(() => { + // Autoplay may be blocked; preview still works on next user interaction + }); + screenVideoRef.current = screenVideo; + + let webcamVideo: HTMLVideoElement | null = null; + if (streams.webcam) { + webcamVideo = document.createElement("video"); + webcamVideo.muted = true; + webcamVideo.playsInline = true; + webcamVideo.srcObject = streams.webcam; + webcamVideo.play().catch(() => { + // Autoplay may be blocked; preview still works on next user interaction + }); + webcamVideoRef.current = webcamVideo; + } + + let running = true; + + const draw = (timestamp: number) => { + if (!running) return; + + // Throttle to ~30fps + if (timestamp - lastDrawRef.current < PREVIEW_FPS_INTERVAL) { + animFrameRef.current = requestAnimationFrame(draw); + return; + } + lastDrawRef.current = timestamp; + + // Match canvas internal resolution to the video's natural size, capped for performance + const videoWidth = screenVideo.videoWidth || 960; + const videoHeight = screenVideo.videoHeight || 540; + const scale = Math.min(1, 960 / videoWidth); + const drawWidth = Math.round(videoWidth * scale); + const drawHeight = Math.round(videoHeight * scale); + + if (canvas.width !== drawWidth || canvas.height !== drawHeight) { + canvas.width = drawWidth; + canvas.height = drawHeight; + } + + // Draw screen capture + if (screenVideo.readyState >= 2) { + ctx.drawImage(screenVideo, 0, 0, drawWidth, drawHeight); + } else { + // Show dark background while waiting for first frame + ctx.fillStyle = "#18181b"; + ctx.fillRect(0, 0, drawWidth, drawHeight); + } + + // Draw webcam PiP overlay (circular, bottom-right) + if (webcamVideo && webcamVideo.readyState >= 2) { + const pipDiameter = Math.min(drawWidth, drawHeight) * PIP_SIZE_RATIO; + const pipX = drawWidth - pipDiameter - PIP_MARGIN; + const pipY = drawHeight - pipDiameter - PIP_MARGIN; + const radius = pipDiameter / 2; + const centerX = pipX + radius; + const centerY = pipY + radius; + + ctx.save(); + + // Shadow behind PiP + ctx.shadowColor = "rgba(0, 0, 0, 0.5)"; + ctx.shadowBlur = 8; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 2; + + // Circular clip + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.clip(); + + // Draw webcam (cover-fit into circle) + const ww = webcamVideo.videoWidth; + const wh = webcamVideo.videoHeight; + const aspectRatio = ww / wh; + let sx = 0; + let sy = 0; + let sw = ww; + let sh = wh; + + if (aspectRatio > 1) { + // Wider than tall: crop sides + sw = wh; + sx = (ww - sw) / 2; + } else { + // Taller than wide: crop top/bottom + sh = ww; + sy = (wh - sh) / 2; + } + + ctx.drawImage(webcamVideo, sx, sy, sw, sh, pipX, pipY, pipDiameter, pipDiameter); + ctx.restore(); + + // Draw border ring + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + ctx.strokeStyle = "rgba(255, 255, 255, 0.2)"; + ctx.lineWidth = 2; + ctx.stroke(); + } + + animFrameRef.current = requestAnimationFrame(draw); + }; + + animFrameRef.current = requestAnimationFrame(draw); + + return () => { + running = false; + cancelAnimationFrame(animFrameRef.current); + screenVideo.srcObject = null; + screenVideoRef.current = null; + if (webcamVideo) { + webcamVideo.srcObject = null; + } + webcamVideoRef.current = null; + }; + }, [streams]); + + // Update webcam video element when webcam stream changes + useEffect(() => { + if (!webcamVideoRef.current && streams?.webcam) { + const webcamVideo = document.createElement("video"); + webcamVideo.muted = true; + webcamVideo.playsInline = true; + webcamVideo.srcObject = streams.webcam; + webcamVideo.play().catch(() => { + // Autoplay may be blocked; preview still works on next user interaction + }); + webcamVideoRef.current = webcamVideo; + } + }, [streams?.webcam]); + + if (!streams) { + return ( +
+
+
+ + + + + +
+

Select a source to preview

+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/hooks/usePreviewStream.ts b/src/hooks/usePreviewStream.ts new file mode 100644 index 00000000..0aaa5379 --- /dev/null +++ b/src/hooks/usePreviewStream.ts @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +const PREVIEW_WIDTH = 1920; +const PREVIEW_HEIGHT = 1080; +const PREVIEW_FRAME_RATE = 30; +const CHROME_MEDIA_SOURCE = "desktop"; + +export interface PreviewStreams { + screen: MediaStream; + webcam: MediaStream | null; +} + +interface UsePreviewStreamOptions { + webcamEnabled: boolean; +} + +export function usePreviewStream({ webcamEnabled }: UsePreviewStreamOptions) { + const [previewActive, setPreviewActive] = useState(false); + const [sourceId, setSourceId] = useState(null); + const screenStreamRef = useRef(null); + const webcamStreamRef = useRef(null); + const [streams, setStreams] = useState(null); + + const stopPreview = useCallback(() => { + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((t) => t.stop()); + screenStreamRef.current = null; + } + if (webcamStreamRef.current) { + webcamStreamRef.current.getTracks().forEach((t) => t.stop()); + webcamStreamRef.current = null; + } + setStreams(null); + setPreviewActive(false); + }, []); + + const startPreview = useCallback( + async (desktopSourceId: string) => { + // Stop any existing preview + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((t) => t.stop()); + screenStreamRef.current = null; + } + + try { + const screenStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: desktopSourceId, + maxWidth: PREVIEW_WIDTH, + maxHeight: PREVIEW_HEIGHT, + maxFrameRate: PREVIEW_FRAME_RATE, + }, + }, + } as unknown as MediaStreamConstraints); + + screenStreamRef.current = screenStream; + setSourceId(desktopSourceId); + setPreviewActive(true); + + // Get webcam if enabled + let webcamStream: MediaStream | null = null; + if (webcamEnabled) { + try { + webcamStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + width: { ideal: 640 }, + height: { ideal: 480 }, + frameRate: { ideal: PREVIEW_FRAME_RATE }, + }, + }); + webcamStreamRef.current = webcamStream; + } catch { + // Webcam not available, continue without it + } + } + + setStreams({ screen: screenStream, webcam: webcamStream }); + return screenStream; + } catch (error) { + console.error("Failed to start preview stream:", error); + stopPreview(); + return null; + } + }, + [webcamEnabled, stopPreview], + ); + + // Handle webcam toggle while preview is active + useEffect(() => { + if (!previewActive) return; + + if (webcamEnabled && !webcamStreamRef.current) { + navigator.mediaDevices + .getUserMedia({ + audio: false, + video: { + width: { ideal: 640 }, + height: { ideal: 480 }, + frameRate: { ideal: PREVIEW_FRAME_RATE }, + }, + }) + .then((webcamStream) => { + webcamStreamRef.current = webcamStream; + setStreams((prev) => (prev ? { ...prev, webcam: webcamStream } : null)); + }) + .catch(() => { + // Webcam unavailable + }); + } else if (!webcamEnabled && webcamStreamRef.current) { + webcamStreamRef.current.getTracks().forEach((t) => t.stop()); + webcamStreamRef.current = null; + setStreams((prev) => (prev ? { ...prev, webcam: null } : null)); + } + }, [webcamEnabled, previewActive]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (screenStreamRef.current) { + screenStreamRef.current.getTracks().forEach((t) => t.stop()); + } + if (webcamStreamRef.current) { + webcamStreamRef.current.getTracks().forEach((t) => t.stop()); + } + }; + }, []); + + /** + * Detach and return the current screen stream for recording use. + * After this, the preview no longer owns the stream (won't stop its tracks). + */ + const detachScreenStream = useCallback(() => { + const stream = screenStreamRef.current; + screenStreamRef.current = null; + return stream; + }, []); + + const detachWebcamStream = useCallback(() => { + const stream = webcamStreamRef.current; + webcamStreamRef.current = null; + return stream; + }, []); + + return { + streams, + previewActive, + sourceId, + startPreview, + stopPreview, + detachScreenStream, + detachWebcamStream, + }; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 2b07e247..2835ca92 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -39,12 +39,14 @@ const WEBCAM_TARGET_WIDTH = 1280; const WEBCAM_TARGET_HEIGHT = 720; const WEBCAM_TARGET_FRAME_RATE = 30; +export type PreviewStreamHandoff = { + screenStream: MediaStream; + webcamStream: MediaStream | null; +}; + type UseScreenRecorderReturn = { recording: boolean; - paused: boolean; - elapsedSeconds: number; - toggleRecording: () => void; - togglePaused: () => void; + toggleRecording: (previewHandoff?: PreviewStreamHandoff) => void; restartRecording: () => void; cancelRecording: () => void; microphoneEnabled: boolean; @@ -365,7 +367,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; }, [teardownMedia]); - const startRecording = async () => { + const startRecording = async (previewHandoff?: PreviewStreamHandoff) => { try { const selectedSource = await window.electronAPI.getSelectedSource(); if (!selectedSource) { @@ -375,41 +377,81 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let screenMediaStream: MediaStream; - const videoConstraints = { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, - maxWidth: TARGET_WIDTH, - maxHeight: TARGET_HEIGHT, - maxFrameRate: TARGET_FRAME_RATE, - minFrameRate: MIN_FRAME_RATE, - }, - }; + if (previewHandoff?.screenStream) { + // Reuse preview stream — upgrade constraints for recording quality + screenMediaStream = previewHandoff.screenStream; + const videoTrack = screenMediaStream.getVideoTracks()[0]; + if (videoTrack) { + try { + await videoTrack.applyConstraints({ + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + }); + } catch { + // Best-effort upgrade, preview constraints still work + } + } - if (systemAudioEnabled) { - try { - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, + // If system audio needed, get audio separately (can't add audio to existing stream) + if (systemAudioEnabled) { + try { + const audioStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + }, }, - }, - video: videoConstraints, - } as unknown as MediaStreamConstraints); - } catch (audioErr) { - console.warn("System audio capture failed, falling back to video-only:", audioErr); - toast.error(t("recording.systemAudioUnavailable")); + video: false, + } as unknown as MediaStreamConstraints); + const audioTrack = audioStream.getAudioTracks()[0]; + if (audioTrack) { + screenMediaStream.addTrack(audioTrack); + } + } catch (audioErr) { + console.warn("System audio capture failed:", audioErr); + toast.error(t("recording.systemAudioUnavailable")); + } + } + } else { + // No preview handoff — create fresh stream (original path) + const videoConstraints = { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + maxWidth: TARGET_WIDTH, + maxHeight: TARGET_HEIGHT, + maxFrameRate: TARGET_FRAME_RATE, + minFrameRate: MIN_FRAME_RATE, + }, + }; + + if (systemAudioEnabled) { + try { + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + }, + }, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } catch (audioErr) { + console.warn("System audio capture failed, falling back to video-only:", audioErr); + toast.error(t("recording.systemAudioUnavailable")); + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } + } else { screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, } as unknown as MediaStreamConstraints); } - } else { - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: videoConstraints, - } as unknown as MediaStreamConstraints); } screenStream.current = screenMediaStream; @@ -437,7 +479,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } - if (webcamEnabled) { + if (previewHandoff?.webcamStream) { + // Reuse preview webcam stream + webcamStream.current = previewHandoff.webcamStream; + } else if (webcamEnabled) { try { webcamStream.current = await navigator.mediaDevices.getUserMedia({ audio: false, @@ -492,17 +537,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { stream.current.addTrack(micAudioTrack); } - try { - await videoTrack.applyConstraints({ - frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, - width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, - height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, - }); - } catch (constraintError) { - console.warn( - "Unable to lock 4K/60fps constraints, using best available track settings.", - constraintError, - ); + if (!previewHandoff) { + try { + await videoTrack.applyConstraints({ + frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, + width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, + height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, + }); + } catch (constraintError) { + console.warn( + "Unable to lock 4K/60fps constraints, using best available track settings.", + constraintError, + ); + } } let { @@ -594,48 +641,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; - const togglePaused = () => { - const activeScreenRecorder = screenRecorder.current?.recorder; - if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { - return; - } - - const activeWebcamRecorder = webcamRecorder.current?.recorder; - - if (activeScreenRecorder.state === "paused") { - try { - activeScreenRecorder.resume(); - if (activeWebcamRecorder?.state === "paused") { - activeWebcamRecorder.resume(); - } - segmentStartedAt.current = Date.now(); - setPaused(false); - } catch (error) { - console.error("Failed to resume recording:", error); - } - return; - } - - if (activeScreenRecorder.state !== "recording") { - return; - } - - try { - accumulatedDurationMs.current = getRecordingDurationMs(); - segmentStartedAt.current = null; - setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); - activeScreenRecorder.pause(); - if (activeWebcamRecorder?.state === "recording") { - activeWebcamRecorder.pause(); - } - setPaused(true); - } catch (error) { - console.error("Failed to pause recording:", error); - } - }; - - const toggleRecording = () => { - recording ? stopRecording.current() : startRecording(); + const toggleRecording = (previewHandoff?: PreviewStreamHandoff) => { + recording ? stopRecording.current() : startRecording(previewHandoff); }; const restartRecording = async () => { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 4e668f3c..cb6431c9 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -17,6 +17,7 @@ interface CursorTelemetryPoint { interface Window { electronAPI: { + getSessionType: () => Promise; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise;