add: LaunchWindow source selector without that much of a jitter. it's still weirdly behaving but it's better

This commit is contained in:
Alan Trebugeais
2026-05-06 21:50:07 +02:00
parent 972ebad9da
commit 8a3539abef
4 changed files with 242 additions and 158 deletions
-14
View File
@@ -20,20 +20,6 @@ function normalizeDesktopSourceName(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
function hasUsableSourceThumbnail(
thumbnail:
| {
isEmpty: () => boolean;
getSize: () => { width: number; height: number };
}
| null
| undefined,
) {
if (!thumbnail || thumbnail.isEmpty()) return false;
const size = thumbnail.getSize();
return size.width > 1 && size.height > 1;
}
function broadcastSelectedSourceChange() {
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
@@ -127,6 +127,12 @@
animation: menuCardIn 0.18s ease;
}
.menuCardTop {
margin-top: 8px;
margin-bottom: auto;
animation: menuCardInTop 0.18s ease;
}
@keyframes menuCardIn {
from {
opacity: 0;
@@ -138,6 +144,17 @@
}
}
@keyframes menuCardInTop {
from {
opacity: 0;
transform: translateY(-12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menuCard::-webkit-scrollbar {
width: 4px;
}
+169 -111
View File
@@ -31,7 +31,6 @@ import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import { ContentClamp } from "../ui/content-clamp";
import ProjectBrowserDialog, {
type ProjectLibraryEntry,
} from "../video-editor/ProjectBrowserDialog";
@@ -45,7 +44,7 @@ import {
} from "./hudMousePassthrough";
import styles from "./LaunchWindow.module.css";
import { RecordingControls } from "./RecordingControls";
import { SourceSelector } from "./SourceSelector";
import { SourceSelectorContent } from "./SourceSelector";
interface DesktopSource {
id: string;
@@ -250,6 +249,20 @@ export function LaunchWindow() {
const isHudDraggingRef = useRef(false);
const isWebcamPreviewDraggingRef = useRef(false);
const [isAtTop, setIsAtTop] = useState(false);
useEffect(() => {
const checkPosition = () => {
const screenHeight = window.screen.availHeight;
const windowY = window.screenY;
setIsAtTop(windowY < screenHeight / 2);
};
checkPosition();
if (activeDropdown !== "none" || projectBrowserOpen) {
checkPosition();
}
}, [activeDropdown, projectBrowserOpen]);
const micDropdownOpen = activeDropdown === "mic";
const webcamDropdownOpen = activeDropdown === "webcam";
const showWebcamControls = webcamEnabled && !recording;
@@ -868,8 +881,12 @@ export function LaunchWindow() {
const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => {
setProjectBrowserOpen(false);
setActiveDropdown(activeDropdown === which ? "none" : which);
if (activeDropdown !== which && which === "sources") fetchSources();
const nextActive = activeDropdown === which ? "none" : which;
setActiveDropdown(nextActive);
if (nextActive !== "none") {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}
if (nextActive !== "none" && which === "sources") fetchSources();
};
const handleSourceSelect = async (source: DesktopSource) => {
@@ -988,16 +1005,24 @@ export function LaunchWindow() {
<>
{platform !== "linux" && (
<>
<SourceSelector
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={sourcesLoading}
onSourceSelect={handleSourceSelect}
onFetchSources={fetchSources}
open={activeDropdown === "sources"}
onOpenChange={(open) => setActiveDropdown(open ? "sources" : "none")}
/>
<Button
variant="outline"
size="lg"
className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] shrink-0 border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all ${activeDropdown === "sources" ? "border-[#3e3e4c] bg-[#20202a]" : ""}`}
onClick={() => toggleDropdown("sources")}
title={selectedSource}
>
<Monitor size={16} className="shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="truncate">{selectedSource}</div>
</div>
<ChevronUp
size={10}
className={`text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200 ${
activeDropdown === "sources" ? "" : "rotate-180"
}`}
/>
</Button>
<Separator orientation="vertical" className="mx-[5px] h-6" />
</>
@@ -1105,12 +1130,16 @@ export function LaunchWindow() {
return (
<div
className="w-full flex items-end justify-center bg-transparent overflow-visible pb-5"
className={`w-full flex justify-center bg-transparent overflow-visible ${
isAtTop ? "items-start pt-5" : "items-end pb-5"
}`}
style={{ height: "100vh" }}
>
<div
ref={hudContentRef}
className="flex flex-col items-center overflow-visible"
className={`flex items-center overflow-visible ${
isAtTop ? "flex-col" : "flex-col-reverse"
}`}
onMouseEnter={() => window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)}
onMouseLeave={() => {
if (
@@ -1122,12 +1151,117 @@ export function LaunchWindow() {
}
}}
>
<div className="flex flex-col items-center pointer-events-auto">
<div
style={{
transform: `translate(${recordingHudOffset.x}px, ${recordingHudOffset.y}px)`,
}}
>
<motion.div
ref={hudBarRef}
layout={!showRecordingWebcamPreview}
transition={hudStateTransition}
className={`${styles.bar} ${isAtTop ? "mt-2" : "mb-2"}`}
>
<div
// On Linux (especially Wayland) the compositor owns window
// placement, so BrowserWindow.setBounds() is silently ignored.
// Fall back to a native OS drag via -webkit-app-region on the
// handle. We still need JS pointer handlers in webcam-preview
// mode (which translates via CSS inside the window), so only
// mark the handle as a native drag region for the IPC path.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
platform === "linux" && !showRecordingWebcamPreview
? styles.electronDrag
: ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}
onPointerUp={handleHudBarPointerUp}
onPointerCancel={handleHudBarPointerUp}
>
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={hudMode}
layout={!showRecordingWebcamPreview}
className={styles.barState}
initial={{
opacity: 0,
y: 10,
scale: 0.985,
filter: "blur(8px)",
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -10,
scale: 0.985,
filter: "blur(6px)",
}}
transition={hudStateTransition}
>
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</div>
{showRecordingWebcamPreview && (
<div
ref={recordingWebcamPreviewContainerRef}
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag}`}
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
}}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={setRecordingWebcamPreviewNode}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
{/* Only the visible HUD content should become interactive. */}
<div
className={styles.menuArea}
ref={dropdownRef}
style={{
transform: `translate(${recordingHudOffset.x}px, ${recordingHudOffset.y}px)`,
pointerEvents: activeDropdown !== "none" || projectBrowserOpen ? "auto" : "none",
}}
onMouseEnter={() => window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)}
onMouseLeave={() => {
if (
!isHudDraggingRef.current &&
!isWebcamPreviewDraggingRef.current &&
!webcamPreviewDragStartRef.current &&
activeDropdown === "none" &&
!projectBrowserOpen
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}}
>
{projectBrowserOpen ? (
@@ -1143,10 +1277,26 @@ export function LaunchWindow() {
/>
</div>
) : null}
{activeDropdown !== "none" && activeDropdown !== "sources" && (
<div className={`${styles.menuCard} ${styles.electronNoDrag}`}>
{activeDropdown !== "none" && (
<div
className={`${styles.menuCard} ${styles.electronNoDrag} ${
isAtTop ? styles.menuCardTop : ""
}`}
>
{activeDropdown === "sources" && (
<>
<div className={styles.ddLabel}>{t("recording.sources")}</div>
<SourceSelectorContent
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={sourcesLoading}
onSourceSelect={handleSourceSelect}
/>
</>
)}
{activeDropdown === "mic" && (
{activeDropdown === "mic" && (
<>
<div className={styles.ddLabel}>
{t("recording.microphone")}
@@ -1418,98 +1568,6 @@ export function LaunchWindow() {
</div>
)}
</div>
<div className="flex flex-col items-center pointer-events-auto">
<div
style={{
transform: `translate(${recordingHudOffset.x}px, ${recordingHudOffset.y}px)`,
}}
>
<motion.div
ref={hudBarRef}
layout={!showRecordingWebcamPreview}
transition={hudStateTransition}
className={`${styles.bar} mb-2`}
>
<div
// On Linux (especially Wayland) the compositor owns window
// placement, so BrowserWindow.setBounds() is silently ignored.
// Fall back to a native OS drag via -webkit-app-region on the
// handle. We still need JS pointer handlers in webcam-preview
// mode (which translates via CSS inside the window), so only
// mark the handle as a native drag region for the IPC path.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
platform === "linux" && !showRecordingWebcamPreview
? styles.electronDrag
: ""
}`}
onPointerDown={handleHudBarPointerDown}
onPointerMove={handleHudBarPointerMove}
onPointerUp={handleHudBarPointerUp}
onPointerCancel={handleHudBarPointerUp}
>
<RxDragHandleDots2 size={14} className="text-[#6b6b78]" />
</div>
<div className={styles.barStateViewport}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={hudMode}
layout={!showRecordingWebcamPreview}
className={styles.barState}
initial={{
opacity: 0,
y: 10,
scale: 0.985,
filter: "blur(8px)",
}}
animate={{
opacity: 1,
y: 0,
scale: 1,
filter: "blur(0px)",
}}
exit={{
opacity: 0,
y: -10,
scale: 0.985,
filter: "blur(6px)",
}}
transition={hudStateTransition}
>
{finalizing
? finalizingControls
: recording
? recordingControls
: idleControls}
</motion.div>
</AnimatePresence>
</div>
</motion.div>
</div>
{showRecordingWebcamPreview && (
<div
ref={recordingWebcamPreviewContainerRef}
className={`${styles.recordingWebcamPreview} ${styles.electronNoDrag}`}
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
}}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={setRecordingWebcamPreviewNode}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
</div>
</div>
);
+56 -33
View File
@@ -117,29 +117,17 @@ interface SourceSelectorProps {
}
/**
* SourceSelector - A rich source selection component with thumbnails
* Uses Radix UI Popover for positioning and accessibility
* SourceSelectorContent - The actual list of sources
*/
export const SourceSelector = React.memo(function SourceSelector({
export const SourceSelectorContent = ({
screenSources = [],
windowSources = [],
selectedSource = "Screen",
loading = false,
onSourceSelect = () => {},
onFetchSources = async () => {},
open = false,
onOpenChange = () => {},
children,
}: SourceSelectorProps) {
}: Pick<SourceSelectorProps, "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect">) => {
const t = useScopedT("launch");
// Fetch sources when popover opens
useEffect(() => {
if (open) {
void onFetchSources();
}
}, [open, onFetchSources]);
// Memoized source item to prevent unnecessary re-renders
const SourceItem = useCallback(
({ source, isSelected }: { source: DesktopSource; isSelected: boolean }) => (
@@ -246,6 +234,52 @@ export const SourceSelector = React.memo(function SourceSelector({
);
}, [windowSources, selectedSource, SourceItem, t]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary" />
</div>
);
}
return (
<div className="max-h-[320px] overflow-y-auto overflow-x-hidden p-2 source-selector-scroll">
{screenSection || windowSection ? (
<>
{screenSection}
{windowSection}
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("recording.noSourcesFound")}
</div>
)}
</div>
);
};
/**
* SourceSelector - A rich source selection component with thumbnails
* Uses Radix UI Popover for positioning and accessibility
*/
export const SourceSelector = React.memo(function SourceSelector({
screenSources = [],
windowSources = [],
selectedSource = "Screen",
loading = false,
onSourceSelect = () => {},
onFetchSources = async () => {},
open = false,
onOpenChange = () => {},
children,
}: SourceSelectorProps) {
// Fetch sources when popover opens
useEffect(() => {
if (open) {
void onFetchSources();
}
}, [open, onFetchSources]);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
@@ -287,24 +321,13 @@ export const SourceSelector = React.memo(function SourceSelector({
collisionPadding={10}
usePortal={false}
>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary" />
</div>
) : (
<div className="max-h-[320px] overflow-y-auto overflow-x-hidden p-2 source-selector-scroll">
{screenSection || windowSection ? (
<>
{screenSection}
{windowSection}
</>
) : (
<div className="text-center py-8 text-sm text-muted-foreground">
{t("recording.noSourcesFound")}
</div>
)}
</div>
)}
<SourceSelectorContent
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={loading}
onSourceSelect={onSourceSelect}
/>
</PopoverContent>
</Popover>
);