Skip to content

feat: add live screen recording preview#319

Open
hamidlabs wants to merge 7 commits intosiddharthvaddem:mainfrom
hamidlabs:feature/live-preview
Open

feat: add live screen recording preview#319
hamidlabs wants to merge 7 commits intosiddharthvaddem:mainfrom
hamidlabs:feature/live-preview

Conversation

@hamidlabs
Copy link
Copy Markdown

@hamidlabs hamidlabs commented Apr 4, 2026

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.

  • Live preview window: Replaces the 500x155 HUD pill bar with a 480x420 resizable preview window showing a real-time screen capture feed
  • Canvas compositor: Renders screen capture + circular webcam PiP overlay at 30fps using a throttled requestAnimationFrame loop, capped at 960px internal resolution for GPU efficiency
  • Stream handoff: Preview stream is seamlessly handed off to MediaRecorder when recording starts — no double getUserMedia calls, no Wayland PipeWire re-prompts
  • Linux PipeWire/Wayland support: Enables WebRTCPipeWireCapturer and ozone-platform-hint flags, adds session type detection IPC for Wayland-aware behavior

Changes

File Change
electron/main.ts PipeWire/Wayland flags for Linux
electron/windows.ts Resized HUD overlay to 480x420, resizable, bottom-right
electron/ipc/handlers.ts get-session-type IPC handler
electron/preload.ts Exposed getSessionType to renderer
electron/electron-env.d.ts Type declaration for new IPC
src/vite-env.d.ts Type declaration for new IPC
src/hooks/usePreviewStream.ts New — preview stream lifecycle management
src/hooks/useScreenRecorder.ts Accept PreviewStreamHandoff to reuse preview streams
src/components/launch/LivePreview.tsx New — canvas-based preview with webcam PiP
src/components/launch/LaunchWindow.tsx Redesigned with integrated live preview

Test plan

  • Select a screen source → live preview should start automatically
  • Toggle webcam → circular PiP overlay appears/disappears in preview
  • Click REC → recording starts using the preview stream (no second permission prompt)
  • Stop recording → preview restarts after brief delay
  • Resize the preview window (380-640px width range)
  • Test on Wayland session (PipeWire screen capture)
  • Test on X11 session (desktopCapturer source enumeration)

Summary by CodeRabbit

New Features

  • Added live canvas-based preview of screen capture with optional webcam picture-in-picture overlay
  • HUD overlay window is now resizable and appears in the taskbar for improved accessibility
  • Added automatic session type detection to optimize display capture on Linux

Refactor

  • Improved recording workflow to leverage preview streams during capture for enhanced performance
  • Restructured HUD layout with dedicated preview region and reorganized control placement

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 4, 2026

📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
Session Type Detection
electron/electron-env.d.ts, electron/ipc/handlers.ts, electron/preload.ts, src/vite-env.d.ts
Added Electron IPC handler and preload bridge to expose getSessionType() method, detecting Linux session type (X11/Wayland) and returning it asynchronously via the renderer process.
HUD Overlay Layout
electron/windows.ts
Modified HUD overlay window from fixed 500×155 to resizable 480×420 with bounds (min 380×320; max 640×560), repositioned to bottom-right anchoring, enabled taskbar visibility and resizing.
Linux PipeWire Support
electron/main.ts
Added Linux-specific command-line flags to enable WebRTC PipeWire capturer and set ozone-platform-hint to auto for Wayland/PipeWire screen capture support.
Live Preview System
src/hooks/usePreviewStream.ts, src/components/launch/LivePreview.tsx
Introduced new hook managing preview stream lifecycle for desktop and optional webcam capture, and new React component rendering preview streams to canvas with 30 FPS throttling and circular webcam PiP overlay.
Recording Stream Handoff
src/hooks/useScreenRecorder.ts
Updated recording hook to accept optional preview stream handoff, allowing external preview streams to be reused in recording with upgraded constraints instead of re-acquiring streams.
Launch Window Refactor
src/components/launch/LaunchWindow.tsx
Removed Mac-specific positioning logic, integrated preview streaming with auto-start polling, reworked recording toggle to detach and pass preview streams to recorder, reorganized JSX layout with dedicated live preview region and separated control bars.

Sequence Diagram

sequenceDiagram
    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)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 A preview streams forth, so crisp and so bright,
Canvas draws frames through the day and the night,
Handoff the streams from preview to record,
Wayland detection now properly assured,
The HUD hops to the corner with pride!

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description provides a clear summary of changes, a detailed changes table, and a comprehensive test plan. However, it does not follow the required template structure, missing sections like Motivation, Type of Change checkbox, and Related Issue(s) link. Reorganize the description to match the template structure: add Motivation section explaining the problem solved, check the Type of Change box (New Feature), link any related issues, and ensure all template sections are addressed.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add live screen recording preview' is concise, clear, and directly describes the primary feature introduced in this PR—the addition of a real-time live preview for screen recording.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feature/live-preview

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +40 to +43
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +184 to +188
setTimeout(async () => {
const source = await window.electronAPI.getSelectedSource();
if (source) {
startPreview(source.id);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_TYPE directly, 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: prevSourceId resets when effect dependencies change, potentially causing redundant preview starts.

The prevSourceId variable is declared inside the effect, so it resets to null whenever the effect re-runs due to dependency changes (recording or startPreview). This means when recording stops, the effect restarts, prevSourceId becomes null, and startPreview may 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

📥 Commits

Reviewing files that changed from the base of the PR and between b101820 and c69debc.

📒 Files selected for processing (10)
  • electron/electron-env.d.ts
  • electron/ipc/handlers.ts
  • electron/main.ts
  • electron/preload.ts
  • electron/windows.ts
  • src/components/launch/LaunchWindow.tsx
  • src/components/launch/LivePreview.tsx
  • src/hooks/usePreviewStream.ts
  • src/hooks/useScreenRecorder.ts
  • src/vite-env.d.ts

Comment on lines +180 to +213
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,
]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +105 to +121
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +151 to +163
// 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]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
// 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.

Comment on lines +24 to +35
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);
}, []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +453 to +456
if (previewHandoff?.webcamStream) {
// Reuse preview webcam stream
webcamStream.current = previewHandoff.webcamStream;
} else if (webcamEnabled) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant