mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-10-04 11:37:44 +00:00
562 lines
16 KiB
TypeScript
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-label={t("recording.openProject")}
|
|
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>
|
|
);
|
|
}
|