Files
Recordly/src/components/launch/LaunchWindow.tsx
T

562 lines
16 KiB
TypeScript

import {
ArrowClockwiseIcon,
CaretUpIcon,
DotsThreeVerticalIcon,
MicrophoneIcon,
MicrophoneSlashIcon,
MinusIcon,
MonitorIcon,
TimerIcon,
VideoCameraIcon,
VideoCameraSlashIcon,
XIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef } from "react";
import { RxDragHandleDots2 } from "react-icons/rx";
import { Separator } from "@/components/ui/separator";
import { useScopedT } from "../../contexts/I18nContext";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { supportsHudCaptureProtection } from "../../lib/hudCaptureProtection";
import { Button } from "../ui/button";
import { HudInteractionContext } from "./contexts/HudInteractionContext";
import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState";
import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import styles from "./LaunchWindow.module.css";
import { MarqueeText } from "./MarqueeText";
import { CountdownPopover } from "./popovers/CountdownPopover";
import {
LaunchPopoverCoordinatorProvider,
useLaunchPopoverCoordinator,
} from "./popovers/LaunchPopoverCoordinator";
import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { ProjectPopover } from "./popovers/ProjectPopover";
import { SourcePopover } from "./popovers/SourcePopover";
import { WebcamPopover } from "./popovers/WebcamPopover";
import { RecordingControls } from "./RecordingControls";
const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;
export function LaunchWindow() {
return (
<LaunchPopoverCoordinatorProvider>
<LaunchWindowContent />
</LaunchPopoverCoordinatorProvider>
);
}
function LaunchWindowContent() {
const t = useScopedT("launch");
const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator();
const {
recording,
paused,
finalizing,
countdownActive,
toggleRecording,
pauseRecording,
resumeRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
countdownDelay,
setCountdownDelay,
preparePermissions,
} = useScreenRecorder();
const { elapsed, formatTime } = useRecordingTimer(recording, paused);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
const {
selectedSource,
hasSelectedSource,
projectLibraryEntries,
handleSourceSelect,
openVideoFile,
openProjectFromLibrary,
syncSelectedSource,
refreshProjectLibrary,
} = useLaunchWindowActions();
const showWebcamControls = webcamEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(
microphoneEnabled || openId === "mic",
microphoneDeviceId,
);
const {
devices: videoDevices,
selectedDeviceId: selectedVideoDeviceId,
setSelectedDeviceId: setSelectedVideoDeviceId,
} = useVideoDevices(webcamEnabled || openId === "webcam");
const {
hudOverlayMousePassthroughSupported,
platform,
appVersion,
hideHudFromCapture,
chooseRecordingsDirectory,
toggleHudCaptureProtection,
} = useLaunchWindowSystemState(preparePermissions);
const hudCaptureProtectionSupported = supportsHudCaptureProtection(platform ?? "");
useEffect(() => {
if (!selectedDeviceId) {
return;
}
setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId);
}, [selectedDeviceId, setMicrophoneDeviceId]);
useEffect(() => {
if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") {
setWebcamDeviceId(selectedVideoDeviceId);
}
}, [selectedVideoDeviceId, setWebcamDeviceId]);
const {
showFloatingWebcamPreview,
setShowFloatingWebcamPreview,
showRecordingWebcamPreview,
webcamPreviewOffset,
recordingWebcamPreviewContainerRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
handleWebcamPreviewPointerDown,
handleWebcamPreviewPointerMove,
handleWebcamPreviewPointerUp,
setWebcamPreviewNode,
setRecordingWebcamPreviewNode,
} = useWebcamPreviewOverlay({
webcamEnabled,
webcamDeviceId,
showWebcamControls,
webcamPopoverOpen: openId === "webcam",
hudOverlayMousePassthroughSupported,
});
useEffect(() => {
window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(showRecordingWebcamPreview);
}, [showRecordingWebcamPreview]);
useEffect(() => {
return () => {
window.electronAPI?.hudOverlaySetWebcamPreviewVisible?.(false);
};
}, []);
const {
recordingHudOffset,
isHudDragging,
hudBarTransformRef,
isHudDraggingRef,
handleHudBarPointerDown,
handleHudBarPointerMove,
handleHudBarPointerUp,
} = useHudBarDrag({
hudContentRef,
hudBarRef,
recordingWebcamPreviewContainerRef,
});
const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } =
useLaunchHudInteractionState({
openId,
isHudDraggingRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
});
useEffect(() => {
let mounted = true;
void window.electronAPI.getSelectedSource().then((source) => {
if (mounted) syncSelectedSource(source);
});
const cleanup = window.electronAPI.onSelectedSourceChanged((source) => {
if (mounted) syncSelectedSource(source);
});
return () => {
mounted = false;
cleanup?.();
};
}, [syncSelectedSource]);
const hudStateTransition = {
duration: 0.24,
ease: [0.22, 1, 0.36, 1] as const,
};
const recordingControls = (
<RecordingControls
paused={paused}
microphoneEnabled={microphoneEnabled}
elapsed={elapsed}
onToggleMicrophone={() => setMicrophoneEnabled(!microphoneEnabled)}
onPauseResume={paused ? resumeRecording : pauseRecording}
onStopRecording={toggleRecording}
onHideHud={() => window.electronAPI?.hudOverlayHide?.()}
onCancelRecording={cancelRecording}
formatTime={formatTime}
/>
);
const idleControls = (
<>
{platform !== "linux" && (
<>
<SourcePopover
selectedSource={selectedSource}
onSourceSelect={handleSourceSelect}
onOpen={beginInteractiveHudAction}
trigger={
<Button
variant="ghost"
size="lg"
className={` ${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] shrink-0 ${openId === "sources" ? "border-[var(--launch-border-strong)] bg-[var(--launch-hover)]" : ""} `}
title={selectedSource}
>
<MonitorIcon size={16} className="shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<MarqueeText text={selectedSource} />
</div>
<CaretUpIcon
size={10}
className={`text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200 ${
openId === "sources" ? "" : "rotate-180"
}`}
/>
</Button>
}
/>
<Separator orientation="vertical" className="mx-[5px] h-6" />
</>
)}
<MicPopover
disabled={recording}
systemAudioEnabled={systemAudioEnabled}
onToggleSystemAudio={() => setSystemAudioEnabled(!systemAudioEnabled)}
microphoneEnabled={microphoneEnabled}
onDisableMicrophone={() => setMicrophoneEnabled(false)}
devices={devices}
microphoneDeviceId={microphoneDeviceId}
selectedDeviceId={selectedDeviceId}
onSelectDevice={(deviceId) => {
setMicrophoneEnabled(true);
setSelectedDeviceId(deviceId);
setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId);
}}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
title={
microphoneEnabled
? t("recording.disableMicrophone")
: t("recording.enableMicrophone")
}
className={microphoneEnabled ? "text-accent" : ""}
>
{microphoneEnabled ? (
<MicrophoneIcon size={18} />
) : (
<MicrophoneSlashIcon size={18} />
)}
</Button>
}
/>
<WebcamPopover
disabled={recording}
webcamEnabled={webcamEnabled}
onDisableWebcam={() => setWebcamEnabled(false)}
canToggleFloatingPreview={canToggleFloatingWebcamPreview(
hudOverlayMousePassthroughSupported,
)}
showFloatingWebcamPreview={showFloatingWebcamPreview}
onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)}
showWebcamControls={showWebcamControls}
setWebcamPreviewNode={setWebcamPreviewNode}
videoDevices={videoDevices}
webcamDeviceId={webcamDeviceId}
selectedVideoDeviceId={selectedVideoDeviceId}
onSelectVideoDevice={(deviceId) => {
setWebcamEnabled(true);
setSelectedVideoDeviceId(deviceId);
setWebcamDeviceId(deviceId);
}}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
title={
webcamEnabled
? t("recording.disableWebcam")
: t("recording.enableWebcam")
}
className={webcamEnabled ? "text-accent" : ""}
>
{webcamEnabled ? (
<VideoCameraIcon size={18} />
) : (
<VideoCameraSlashIcon size={18} />
)}
</Button>
}
/>
<CountdownPopover
countdownDelay={countdownDelay}
onSelectDelay={setCountdownDelay}
trigger={
<Button
variant="ghost"
size="icon"
iconSize="lg"
title={t("recording.countdownDelay")}
className={countdownDelay > 0 ? "text-accent" : ""}
>
<TimerIcon size={18} />
</Button>
}
/>
<Button
type="button"
variant="destructive"
size="icon"
className={styles.electronNoDrag}
onClick={
hasSelectedSource || platform === "linux"
? toggleRecording
: () => {
beginInteractiveHudAction();
requestOpen("sources");
}
}
disabled={countdownActive}
title={t("recording.record")}
>
<div className={styles.recDot} />
</Button>
<Separator orientation="vertical" className="mx-[5px] h-6" />
<div className="relative w-0 h-0">
<ProjectPopover
entries={projectLibraryEntries}
onOpenProject={openProjectFromLibrary}
trigger={
<Button
aria-hidden="true"
tabIndex={-1}
variant="ghost"
className="absolute size-px min-w-0 p-0 pointer-events-none opacity-0"
/>
}
/>
</div>
<MorePopover
supportsHudCaptureProtection={hudCaptureProtectionSupported}
hideHudFromCapture={hideHudFromCapture}
onToggleHudCaptureProtection={() => {
void toggleHudCaptureProtection();
}}
onChooseRecordingsDirectory={() => {
void chooseRecordingsDirectory();
}}
onOpenVideoFile={() => {
void openVideoFile();
}}
onOpenProjectBrowser={() => {
refreshProjectLibrary().then(() => {
requestOpen("projects");
});
}}
showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW}
onPreviewUpdateUi={() => {
if (openId) requestClose(openId);
void window.electronAPI.previewUpdateToast().catch((error) => {
console.warn("Failed to preview update toast:", error);
});
}}
appVersion={appVersion}
trigger={
<Button variant="ghost" size="icon" iconSize="lg" title={t("recording.more")}>
<DotsThreeVerticalIcon size={18} />
</Button>
}
/>
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={() => window.electronAPI?.hudOverlayHide?.()}
title={t("recording.hideHud")}
>
<MinusIcon size={16} />
</Button>
<Button
variant="ghost"
size="icon"
iconSize="lg"
onClick={() => window.electronAPI?.hudOverlayClose?.()}
title={t("recording.closeApp")}
>
<XIcon size={16} />
</Button>
</>
);
const finalizingControls = (
<div className={styles.finalizingState}>
<ArrowClockwiseIcon size={15} className={styles.finalizingSpin} />
<div className={styles.finalizingCopy}>
<span>{t("recording.preparing", "Preparing recording")}</span>
<small>{t("recording.preparingSubtitle", "Opening the editor in a moment")}</small>
</div>
</div>
);
const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle";
const useNativeHudBarDrag =
platform === "linux" || hudOverlayMousePassthroughSupported === false;
const shouldAnimateHudLayout = !recording && !showRecordingWebcamPreview && !isHudDragging;
return (
<HudInteractionContext.Provider
value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }}
>
<div
className="w-full flex justify-center bg-transparent overflow-visible items-end pb-5 pointer-events-none"
style={{ height: "100vh" }}
>
<div
ref={hudContentRef}
className="flex items-center overflow-visible flex-col-reverse pointer-events-none"
>
<div className="flex flex-col items-center pointer-events-none p-2">
<div
ref={hudBarTransformRef}
style={{
transform: `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`,
}}
>
<motion.div
ref={hudBarRef}
layout={shouldAnimateHudLayout}
transition={hudStateTransition}
className={`${styles.bar} launch-theme mb-2 pointer-events-auto`}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
>
<div
// Linux compositors and non-passthrough Windows fallback windows
// need native window dragging; the JS drag path only translates
// content inside the HUD window.
className={`flex items-center px-0.5 cursor-grab active:cursor-grabbing ${
useNativeHudBarDrag ? 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={shouldAnimateHudLayout}
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} pointer-events-auto`}
data-hud-interactive
title={t("recording.webcam")}
style={{
transform: `translate(${webcamPreviewOffset.x}px, ${webcamPreviewOffset.y}px)`,
}}
onMouseEnter={handleHudMouseEnter}
onMouseLeave={handleHudMouseLeave}
onPointerDown={handleWebcamPreviewPointerDown}
onPointerMove={handleWebcamPreviewPointerMove}
onPointerUp={handleWebcamPreviewPointerUp}
onPointerCancel={handleWebcamPreviewPointerUp}
>
<video
ref={setRecordingWebcamPreviewNode}
className={styles.recordingWebcamPreviewVideo}
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
)}
</div>
</div>
</div>
</HudInteractionContext.Provider>
);
}