feat: add live screen recording preview#319
feat: add live screen recording preview#319hamidlabs wants to merge 7 commits intosiddharthvaddem:mainfrom
Conversation
📝 WalkthroughWalkthroughThe changes introduce session-type detection for cross-platform audio/video capture, add a live preview streaming system with canvas-based rendering, and refactor the launch window to integrate preview streaming with the recording workflow while updating HUD overlay window layout. Changes
Sequence DiagramsequenceDiagram
participant Renderer as React Renderer
participant LaunchWindow as LaunchWindow Component
participant UsePreview as usePreviewStream Hook
participant MediaAPI as Chrome MediaAPI
participant LivePreview as LivePreview Component
participant UseRecorder as useScreenRecorder Hook
Renderer->>LaunchWindow: render with webcamEnabled
LaunchWindow->>UsePreview: call usePreviewStream hook
UsePreview->>UsePreview: initialize state (previewActive=false)
LaunchWindow->>UsePreview: call startPreview(desktopSourceId)
UsePreview->>MediaAPI: getUserMedia with desktop constraints
MediaAPI-->>UsePreview: screenStream
alt webcamEnabled is true
UsePreview->>MediaAPI: getUserMedia for webcam
MediaAPI-->>UsePreview: webcamStream
end
UsePreview->>UsePreview: set streams, previewActive=true
UsePreview-->>LaunchWindow: return {streams, previewActive}
LaunchWindow->>LivePreview: pass streams to render
LivePreview->>MediaAPI: create video elements
MediaAPI-->>LivePreview: video elements ready
LivePreview->>LivePreview: requestAnimationFrame loop
LivePreview->>LivePreview: draw to canvas (30 FPS)
LivePreview-->>Renderer: display canvas preview
Renderer->>LaunchWindow: user clicks record button
LaunchWindow->>UsePreview: detachScreenStream & detachWebcamStream
UsePreview-->>LaunchWindow: return streams (clear refs)
LaunchWindow->>UseRecorder: toggleRecording with preview handoff
UseRecorder->>UseRecorder: startRecording(handoff)
UseRecorder->>MediaAPI: apply constraints to handoff stream
MediaAPI-->>UseRecorder: upgraded constraints applied
UseRecorder->>UseRecorder: start recording
Renderer->>LaunchWindow: user clicks stop
UseRecorder->>UseRecorder: stopRecording
LaunchWindow->>UsePreview: startPreview (restart after ~500ms)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
⚔️ Resolve merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c69debc1fe
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (screenStreamRef.current) { | ||
| screenStreamRef.current.getTracks().forEach((t) => t.stop()); | ||
| screenStreamRef.current = null; | ||
| } |
There was a problem hiding this comment.
Stop webcam tracks when restarting preview
startPreview tears down only screenStreamRef, then can acquire a new webcam stream and overwrite webcamStreamRef. If the user changes capture sources while webcam preview is enabled, the previous webcam tracks are never stopped and become unreachable, leaving extra camera captures running until unmount. This leaks media resources and can keep the camera active unexpectedly.
Useful? React with 👍 / 👎.
| setTimeout(async () => { | ||
| const source = await window.electronAPI.getSelectedSource(); | ||
| if (source) { | ||
| startPreview(source.id); | ||
| } |
There was a problem hiding this comment.
Guard delayed preview restart against new recording
After stopping recording, this timeout always calls startPreview after 500ms without checking whether recording has started again. In a quick stop→start interaction, the callback can open a new preview capture during an active recording, creating an extra stream that is not part of the recorder flow and may trigger redundant capture prompts or resource contention.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
electron/ipc/handlers.ts (1)
221-224: Consider normalizing session type to known values.The handler returns
process.env.XDG_SESSION_TYPEdirectly, which could contain unexpected values beyond "wayland" or "x11" (e.g., "tty", "mir", or custom values). If the consumer code expects only "wayland" or "x11", consider normalizing:♻️ Optional: Normalize to known session types
ipcMain.handle("get-session-type", () => { if (process.platform !== "linux") return "x11"; - return process.env.XDG_SESSION_TYPE || (process.env.WAYLAND_DISPLAY ? "wayland" : "x11"); + const sessionType = process.env.XDG_SESSION_TYPE?.toLowerCase(); + if (sessionType === "wayland") return "wayland"; + if (sessionType === "x11") return "x11"; + // Fallback detection + return process.env.WAYLAND_DISPLAY ? "wayland" : "x11"; });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@electron/ipc/handlers.ts` around lines 221 - 224, The get-session-type IPC handler returns process.env.XDG_SESSION_TYPE directly which may be unexpected values; update the ipcMain.handle("get-session-type", ...) implementation to normalize the session type to only "wayland" or "x11": read XDG_SESSION_TYPE (and fall back to checking WAYLAND_DISPLAY as before), lowercase it, then return "wayland" if it equals "wayland" and return "x11" for any other value. Ensure you update the logic inside the ipcMain.handle callback so consumers always receive one of the two known values.src/components/launch/LaunchWindow.tsx (1)
145-172:prevSourceIdresets when effect dependencies change, potentially causing redundant preview starts.The
prevSourceIdvariable is declared inside the effect, so it resets tonullwhenever the effect re-runs due to dependency changes (recordingorstartPreview). This means when recording stops, the effect restarts,prevSourceIdbecomesnull, andstartPreviewmay be called even for the same source.♻️ Use useRef to persist prevSourceId across effect runs
+const prevSourceIdRef = useRef<string | null>(null); // 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; + if (source.id !== prevSourceIdRef.current && !recording) { + prevSourceIdRef.current = source.id; startPreview(source.id); } } else { setSelectedSource("Screen"); setHasSelectedSource(false); } } };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/components/launch/LaunchWindow.tsx` around lines 145 - 172, prevSourceId is declared inside the useEffect so it resets whenever the effect re-runs causing duplicate startPreview calls; move that state to a ref (e.g., prevSourceIdRef = useRef<string | null>(null)) at component scope and replace uses of prevSourceId in the effect with prevSourceIdRef.current, updating prevSourceIdRef.current = source.id after calling startPreview(source.id) and when selecting a source so the previous source id persists across effect runs; keep the rest of the checkSelectedSource logic and the interval handling the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/components/launch/LaunchWindow.tsx`:
- Around line 180-213: The setTimeout inside handleToggleRecording can fire
after unmount; add a ref (e.g., restartPreviewTimeoutRef) to store the timeout
id when scheduling the 500ms restart and use that ref to clearTimeout if needed,
update the callback to assign the timeout to restartPreviewTimeoutRef.current
instead of an anonymous timeout, and add a cleanup useEffect that clears
restartPreviewTimeoutRef.current on unmount to prevent calling startPreview
after the component has been unmounted.
In `@src/components/launch/LivePreview.tsx`:
- Around line 151-163: The effect in LivePreview that creates a video element
when streams.webcam becomes available can leak resources; update the useEffect
(the one that checks webcamVideoRef.current and streams?.webcam) to return a
cleanup function that tears down the created element: if webcamVideoRef.current
exists and its srcObject equals the stream, pause the video, remove it from the
DOM, set webcamVideoRef.current.srcObject = null, stop any tracks on the
MediaStream, and clear webcamVideoRef.current; this ensures toggling the webcam
or unmounting releases streams and DOM nodes even when the parent streams object
identity does not change.
- Around line 105-121: The crop math uses webcamVideo.videoWidth/height and can
divide by zero before metadata loads; inside the drawing routine in
LivePreview.tsx where ww, wh, aspectRatio, sx, sy, sw, sh are computed, guard by
checking webcamVideo.videoWidth and webcamVideo.videoHeight > 0 and bail out or
skip cropping/drawing until they are valid (or set safe defaults like 1) so
aspectRatio is never Infinity/NaN and drawImage gets valid sx/sy/sw/sh values.
In `@src/hooks/usePreviewStream.ts`:
- Around line 24-35: The preview startup/stop code must serialize and abort
stale webcam requests to avoid double prompts and leaked tracks: add a
latestRequestRef (useRef<symbol|null>) and, in startPreview and in the effect
that also calls getUserMedia, create a new unique symbol and assign it to
latestRequestRef before awaiting navigator.mediaDevices.getUserMedia; after the
await verify the symbol still matches latestRequestRef.current and only then
assign webcamStreamRef.current and setStreams; if it does not match, stop the
newly obtained tracks immediately; update stopPreview to clear
latestRequestRef.current (set to null) so late resolves know the request was
cancelled and always stop any stream returned by stale requests.
In `@src/hooks/useScreenRecorder.ts`:
- Around line 453-456: When reusing the preview handoff stream in
useScreenRecorder, the preview track must be upgraded to recording constraints
instead of using the preview resolution as-is; locate the branch that sets
webcamStream.current = previewHandoff.webcamStream and before assigning or
before starting the recorder call applyConstraints on the preview video track
(the track from previewHandoff.webcamStream) with the same constraints used in
the fresh-capture branch (e.g. width:1280, height:720, frameRate:30), await it
and catch errors so you can fall back to the original track if applyConstraints
fails; adjust references to webcamStream.current and the code that starts the
recorder so it uses the upgraded track.
---
Nitpick comments:
In `@electron/ipc/handlers.ts`:
- Around line 221-224: The get-session-type IPC handler returns
process.env.XDG_SESSION_TYPE directly which may be unexpected values; update the
ipcMain.handle("get-session-type", ...) implementation to normalize the session
type to only "wayland" or "x11": read XDG_SESSION_TYPE (and fall back to
checking WAYLAND_DISPLAY as before), lowercase it, then return "wayland" if it
equals "wayland" and return "x11" for any other value. Ensure you update the
logic inside the ipcMain.handle callback so consumers always receive one of the
two known values.
In `@src/components/launch/LaunchWindow.tsx`:
- Around line 145-172: prevSourceId is declared inside the useEffect so it
resets whenever the effect re-runs causing duplicate startPreview calls; move
that state to a ref (e.g., prevSourceIdRef = useRef<string | null>(null)) at
component scope and replace uses of prevSourceId in the effect with
prevSourceIdRef.current, updating prevSourceIdRef.current = source.id after
calling startPreview(source.id) and when selecting a source so the previous
source id persists across effect runs; keep the rest of the checkSelectedSource
logic and the interval handling the same.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: cb9512f1-35fc-4232-8232-fa7c1dd78fd9
📒 Files selected for processing (10)
electron/electron-env.d.tselectron/ipc/handlers.tselectron/main.tselectron/preload.tselectron/windows.tssrc/components/launch/LaunchWindow.tsxsrc/components/launch/LivePreview.tsxsrc/hooks/usePreviewStream.tssrc/hooks/useScreenRecorder.tssrc/vite-env.d.ts
| 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, | ||
| ]); |
There was a problem hiding this comment.
Potential issue: setTimeout in handleToggleRecording not cleaned up on unmount.
The 500ms timeout for restarting preview after recording stops (lines 184-189) could fire after the component unmounts, causing a state update on an unmounted component or calling startPreview when it's no longer valid.
♻️ Suggested fix: Track timeout with useRef and clear on unmount
Add a ref to track the timeout:
const restartPreviewTimeoutRef = useRef<NodeJS.Timeout | null>(null);Update handleToggleRecording:
if (recording) {
toggleRecording();
// Restart preview after recording stops
- setTimeout(async () => {
+ restartPreviewTimeoutRef.current = setTimeout(async () => {
const source = await window.electronAPI.getSelectedSource();
if (source) {
startPreview(source.id);
}
}, 500);
}Add cleanup effect:
useEffect(() => {
return () => {
if (restartPreviewTimeoutRef.current) {
clearTimeout(restartPreviewTimeoutRef.current);
}
};
}, []);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/launch/LaunchWindow.tsx` around lines 180 - 213, The
setTimeout inside handleToggleRecording can fire after unmount; add a ref (e.g.,
restartPreviewTimeoutRef) to store the timeout id when scheduling the 500ms
restart and use that ref to clearTimeout if needed, update the callback to
assign the timeout to restartPreviewTimeoutRef.current instead of an anonymous
timeout, and add a cleanup useEffect that clears
restartPreviewTimeoutRef.current on unmount to prevent calling startPreview
after the component has been unmounted.
| 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; | ||
| } |
There was a problem hiding this comment.
Guard against division by zero when webcam video dimensions are not yet available.
If webcamVideo.videoWidth or videoHeight is 0 (before metadata loads), the aspect ratio calculation will produce Infinity or NaN, and the crop calculations may produce invalid values for drawImage.
🛡️ Proposed fix
// Draw webcam (cover-fit into circle)
const ww = webcamVideo.videoWidth;
const wh = webcamVideo.videoHeight;
+ if (ww === 0 || wh === 0) {
+ ctx.restore();
+ animFrameRef.current = requestAnimationFrame(draw);
+ return;
+ }
const aspectRatio = ww / wh;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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; | |
| } | |
| const ww = webcamVideo.videoWidth; | |
| const wh = webcamVideo.videoHeight; | |
| if (ww === 0 || wh === 0) { | |
| ctx.restore(); | |
| animFrameRef.current = requestAnimationFrame(draw); | |
| return; | |
| } | |
| 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; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/launch/LivePreview.tsx` around lines 105 - 121, The crop math
uses webcamVideo.videoWidth/height and can divide by zero before metadata loads;
inside the drawing routine in LivePreview.tsx where ww, wh, aspectRatio, sx, sy,
sw, sh are computed, guard by checking webcamVideo.videoWidth and
webcamVideo.videoHeight > 0 and bail out or skip cropping/drawing until they are
valid (or set safe defaults like 1) so aspectRatio is never Infinity/NaN and
drawImage gets valid sx/sy/sw/sh values.
| // 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]); |
There was a problem hiding this comment.
Potential resource leak when webcam stream is added after initial mount.
This effect creates a new video element when streams.webcam becomes available but webcamVideoRef.current is null. However, if the webcam is toggled on/off multiple times while the main streams object reference stays the same, the video element created here won't have its srcObject cleared because the main effect's cleanup only runs when streams changes.
♻️ Suggested fix: Add cleanup to this effect
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;
}
+
+ return () => {
+ if (webcamVideoRef.current && !streams?.webcam) {
+ webcamVideoRef.current.srcObject = null;
+ webcamVideoRef.current = null;
+ }
+ };
}, [streams?.webcam]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // 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]); | |
| // 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; | |
| } | |
| return () => { | |
| if (webcamVideoRef.current && !streams?.webcam) { | |
| webcamVideoRef.current.srcObject = null; | |
| webcamVideoRef.current = null; | |
| } | |
| }; | |
| }, [streams?.webcam]); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/components/launch/LivePreview.tsx` around lines 151 - 163, The effect in
LivePreview that creates a video element when streams.webcam becomes available
can leak resources; update the useEffect (the one that checks
webcamVideoRef.current and streams?.webcam) to return a cleanup function that
tears down the created element: if webcamVideoRef.current exists and its
srcObject equals the stream, pause the video, remove it from the DOM, set
webcamVideoRef.current.srcObject = null, stop any tracks on the MediaStream, and
clear webcamVideoRef.current; this ensures toggling the webcam or unmounting
releases streams and DOM nodes even when the parent streams object identity does
not change.
| 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); | ||
| }, []); |
There was a problem hiding this comment.
Let a single code path own preview acquisition.
startPreview() and the Line 96 effect both open the webcam. Because Line 61 flips previewActive before the Line 67 request resolves, the effect can start a second getUserMedia() call while the first is still in flight; on source changes the new request overwrites the previous webcam ref without stopping it; and late resolves are never checked against the current preview request. That can double-prompt, leak camera capture, or resurrect a preview after it was stopped.
💡 Proposed fix
Add a request token near the existing refs:
const latestRequestRef = useRef<symbol | null>(null); const stopPreview = useCallback(() => {
+ latestRequestRef.current = null;
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
}
if (webcamStreamRef.current) {
@@
const startPreview = useCallback(
async (desktopSourceId: string) => {
+ const requestId = Symbol("preview-request");
+ latestRequestRef.current = requestId;
+
// Stop any existing preview
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;
+ }
try {
const screenStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
@@
},
} as unknown as MediaStreamConstraints);
+ if (latestRequestRef.current !== requestId) {
+ screenStream.getTracks().forEach((t) => t.stop());
+ return null;
+ }
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 });
+ setStreams({ screen: screenStream, webcam: null });
return screenStream; useEffect(() => {
if (!previewActive) return;
+ let cancelled = false;
if (webcamEnabled && !webcamStreamRef.current) {
navigator.mediaDevices
.getUserMedia({
audio: false,
@@
},
})
.then((webcamStream) => {
+ if (cancelled || !screenStreamRef.current) {
+ webcamStream.getTracks().forEach((t) => t.stop());
+ return;
+ }
webcamStreamRef.current = webcamStream;
setStreams((prev) => (prev ? { ...prev, webcam: webcamStream } : null));
})
@@
});
} else if (!webcamEnabled && webcamStreamRef.current) {
webcamStreamRef.current.getTracks().forEach((t) => t.stop());
webcamStreamRef.current = null;
setStreams((prev) => (prev ? { ...prev, webcam: null } : null));
}
+
+ return () => {
+ cancelled = true;
+ };
}, [webcamEnabled, previewActive]);Also applies to: 37-79, 92-118
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/usePreviewStream.ts` around lines 24 - 35, The preview startup/stop
code must serialize and abort stale webcam requests to avoid double prompts and
leaked tracks: add a latestRequestRef (useRef<symbol|null>) and, in startPreview
and in the effect that also calls getUserMedia, create a new unique symbol and
assign it to latestRequestRef before awaiting
navigator.mediaDevices.getUserMedia; after the await verify the symbol still
matches latestRequestRef.current and only then assign webcamStreamRef.current
and setStreams; if it does not match, stop the newly obtained tracks
immediately; update stopPreview to clear latestRequestRef.current (set to null)
so late resolves know the request was cancelled and always stop any stream
returned by stale requests.
| if (previewHandoff?.webcamStream) { | ||
| // Reuse preview webcam stream | ||
| webcamStream.current = previewHandoff.webcamStream; | ||
| } else if (webcamEnabled) { |
There was a problem hiding this comment.
Upgrade the handed-off webcam track before starting the recorder.
The fresh-capture branch still requests 1280×720/30 on Lines 458-464, but Lines 453-456 reuse the preview webcam stream as-is. Since src/hooks/usePreviewStream.ts creates that stream at preview resolution, recording can now fall back to preview quality whenever handoff is used.
💡 Proposed fix
if (previewHandoff?.webcamStream) {
// Reuse preview webcam stream
webcamStream.current = previewHandoff.webcamStream;
+ const webcamTrack = webcamStream.current.getVideoTracks()[0];
+ if (webcamTrack) {
+ try {
+ await webcamTrack.applyConstraints({
+ width: { ideal: WEBCAM_TARGET_WIDTH },
+ height: { ideal: WEBCAM_TARGET_HEIGHT },
+ frameRate: {
+ ideal: WEBCAM_TARGET_FRAME_RATE,
+ max: WEBCAM_TARGET_FRAME_RATE,
+ },
+ });
+ } catch {
+ // Best-effort upgrade; keep preview settings if unsupported.
+ }
+ }
} else if (webcamEnabled) {Also applies to: 458-465
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/hooks/useScreenRecorder.ts` around lines 453 - 456, When reusing the
preview handoff stream in useScreenRecorder, the preview track must be upgraded
to recording constraints instead of using the preview resolution as-is; locate
the branch that sets webcamStream.current = previewHandoff.webcamStream and
before assigning or before starting the recorder call applyConstraints on the
preview video track (the track from previewHandoff.webcamStream) with the same
constraints used in the fresh-capture branch (e.g. width:1280, height:720,
frameRate:30), await it and catch errors so you can fall back to the original
track if applyConstraints fails; adjust references to webcamStream.current and
the code that starts the recorder so it uses the upgraded track.
Enable WebRTCPipeWireCapturer and ozone-platform-hint flags on Linux to support screen capture via PipeWire on Wayland sessions.
Expand from 500x155 fixed pill bar to 480x420 resizable window (380-640 width range) to accommodate the live preview area. Position bottom-right instead of bottom-center.
Add get-session-type IPC handler to detect display server type on Linux, enabling Wayland-aware source selection in the renderer.
Manages the MediaStream lifecycle for screen capture preview: - Starts/stops preview streams with source switching support - Handles webcam stream alongside screen capture - Supports stream detachment for seamless handoff to MediaRecorder (avoids double getUserMedia calls and Wayland re-prompts)
Real-time canvas-based preview that composites screen capture with a circular webcam PiP overlay. Renders at 30fps with throttling, caps internal resolution at 960px for GPU efficiency. Shows a placeholder when no source is selected.
Accept optional PreviewStreamHandoff in toggleRecording/startRecording to reuse existing preview MediaStreams instead of creating new ones. This avoids double getUserMedia calls and PipeWire re-prompts on Wayland. When a handoff is provided, video constraints are upgraded in-place.
Replace the 500x155 HUD pill bar with a full preview window featuring: - Live screen capture preview that starts when a source is selected - Canvas-composited webcam PiP overlay in the preview - Recording indicator in the title bar - Stream handoff from preview to recorder (no double getUserMedia) - Auto-restart preview after recording stops - Glass-morphism container styling
Summary
Adds a real-time live preview to the launch window, similar to OBS Studio's preview panel. Users can now see exactly what will be recorded before and during recording.
requestAnimationFrameloop, capped at 960px internal resolution for GPU efficiencyMediaRecorderwhen recording starts — no doublegetUserMediacalls, no Wayland PipeWire re-promptsWebRTCPipeWireCapturerandozone-platform-hintflags, adds session type detection IPC for Wayland-aware behaviorChanges
electron/main.tselectron/windows.tselectron/ipc/handlers.tsget-session-typeIPC handlerelectron/preload.tsgetSessionTypeto rendererelectron/electron-env.d.tssrc/vite-env.d.tssrc/hooks/usePreviewStream.tssrc/hooks/useScreenRecorder.tsPreviewStreamHandoffto reuse preview streamssrc/components/launch/LivePreview.tsxsrc/components/launch/LaunchWindow.tsxTest plan
Summary by CodeRabbit
New Features
Refactor