Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 73 additions & 18 deletions src/components/launch/LaunchWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChevronDown, Languages } from "lucide-react";
import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
import { FaPauseCircle, FaPlayCircle, FaRegStopCircle } from "react-icons/fa";
import { FaFolderOpen } from "react-icons/fa6";
import { FiMinus, FiX } from "react-icons/fi";
import {
Expand Down Expand Up @@ -41,6 +41,8 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
pause: { icon: FaPauseCircle, size: ICON_SIZE },
resume: { icon: FaPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
restart: { icon: MdRestartAlt, size: ICON_SIZE },
record: { icon: BsRecordCircle, size: ICON_SIZE },
Expand Down Expand Up @@ -77,7 +79,10 @@ export function LaunchWindow() {

const {
recording,
paused,
toggleRecording,
pauseRecording,
resumeRecording,
restartRecording,
microphoneEnabled,
setMicrophoneEnabled,
Expand All @@ -93,6 +98,9 @@ export function LaunchWindow() {
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);

const [pausedAt, setPausedAt] = useState<number | null>(null);
const [pausedTotalMs, setPausedTotalMs] = useState(0);

const showMicControls = microphoneEnabled && !recording;
const showWebcamControls = webcamEnabled && !recording;

Expand Down Expand Up @@ -147,24 +155,48 @@ export function LaunchWindow() {
}, [selectedCameraId, setWebcamDeviceId]);

useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) setRecordingStart(Date.now());
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
}
}, 1000);
} else {
setRecordingStart(null);
setElapsed(0);
if (timer) clearInterval(timer);
if (!recordingStart) {
setRecordingStart(Date.now());
setElapsed(0);
}
return;
}
return () => {
if (timer) clearInterval(timer);
};

setRecordingStart(null);
setElapsed(0);
setPausedAt(null);
setPausedTotalMs(0);
}, [recording, recordingStart]);

useEffect(() => {
if (!recording || !recordingStart) return;

if (paused) {
if (!pausedAt) {
setPausedAt(Date.now());
}
return;
}

if (pausedAt) {
setPausedTotalMs((prev) => prev + (Date.now() - pausedAt));
setPausedAt(null);
}
}, [recording, paused, recordingStart, pausedAt]);

useEffect(() => {
if (!recording || !recordingStart || paused) return;

const timer = setInterval(() => {
const livePaused = pausedAt ? Date.now() - pausedAt : 0;
const effectiveMs = Math.max(0, Date.now() - recordingStart - pausedTotalMs - livePaused);
setElapsed(Math.floor(effectiveMs / 1000));
}, 250);

return () => clearInterval(timer);
}, [recording, paused, recordingStart, pausedAt, pausedTotalMs]);

useEffect(() => {
if (!import.meta.env.DEV) {
return;
Expand Down Expand Up @@ -274,7 +306,10 @@ export function LaunchWindow() {
onMouseLeave={() => setIsMicHovered(false)}
onFocus={() => setIsMicFocused(true)}
onBlur={() => setIsMicFocused(false)}
style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
style={{
width: micExpanded ? "240px" : "140px",
transition: "width 300ms ease",
}}
>
<div className="relative flex-1 min-w-0">
{!micExpanded && (
Expand Down Expand Up @@ -318,7 +353,10 @@ export function LaunchWindow() {
onMouseLeave={() => setIsWebcamHovered(false)}
onFocus={() => setIsWebcamFocused(true)}
onBlur={() => setIsWebcamFocused(false)}
style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }}
style={{
width: webcamExpanded ? "240px" : "140px",
transition: "width 300ms ease",
}}
>
<div className="relative flex-1 min-w-0">
{!webcamExpanded && (
Expand Down Expand Up @@ -444,10 +482,27 @@ export function LaunchWindow() {
</button>
</div>

{/* Resume/Pause recording */}
{recording && (
<Tooltip content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}>
<button
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={paused ? resumeRecording : pauseRecording}
style={{ flex: "0 0 auto" }}
>
{paused ? getIcon("resume", "text-amber-300") : getIcon("pause", "text-amber-300")}
</button>
</Tooltip>
)}

{/* Record/Stop group */}
<button
className={`flex items-center gap-0.5 rounded-full p-2 transition-colors duration-150 ${styles.electronNoDrag} ${
recording ? "animate-record-pulse bg-red-500/10" : "bg-white/5 hover:bg-white/[0.08]"
recording
? paused
? "bg-amber-500/10"
: "animate-record-pulse bg-red-500/10"
: "bg-white/5 hover:bg-white/[0.08]"
}`}
onClick={toggleRecording}
disabled={!hasSelectedSource && !recording}
Expand Down
80 changes: 74 additions & 6 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ const WEBCAM_TARGET_FRAME_RATE = 30;

type UseScreenRecorderReturn = {
recording: boolean;
paused: boolean;
toggleRecording: () => void;
pauseRecording: () => void;
resumeRecording: () => void;
restartRecording: () => void;
microphoneEnabled: boolean;
setMicrophoneEnabled: (enabled: boolean) => void;
Expand Down Expand Up @@ -85,6 +88,7 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions
export function useScreenRecorder(): UseScreenRecorderReturn {
const t = useScopedT("editor");
const [recording, setRecording] = useState(false);
const [paused, setPaused] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
Expand All @@ -98,6 +102,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const webcamStream = useRef<MediaStream | null>(null);
const mixingContext = useRef<AudioContext | null>(null);
const startTime = useRef<number>(0);
const pausedStartedAt = useRef<number | null>(null);
const pausedDurationMs = useRef(0);
const recordingId = useRef<number>(0);
const finalizingRecordingId = useRef<number | null>(null);
const allowAutoFinalize = useRef(false);
Expand Down Expand Up @@ -200,8 +206,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
webcamRecorder.current = null;
}

pausedStartedAt.current = null;
pausedDurationMs.current = 0;

teardownMedia();
setRecording(false);
setPaused(false);
window.electronAPI?.setRecordingState(false);

void (async () => {
Expand Down Expand Up @@ -273,7 +283,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}

const activeWebcamRecorder = webcamRecorder.current;
const duration = Date.now() - startTime.current;

if (pausedStartedAt.current) {
pausedDurationMs.current += Date.now() - pausedStartedAt.current;
pausedStartedAt.current = null;
}

const duration = Date.now() - startTime.current - pausedDurationMs.current;
const activeRecordingId = recordingId.current;

finalizeRecording(
Expand All @@ -283,15 +299,21 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
activeRecordingId,
);

if (activeScreenRecorder.recorder.state === "recording") {
if (
activeScreenRecorder.recorder.state === "recording" ||
activeScreenRecorder.recorder.state === "paused"
) {
try {
activeScreenRecorder.recorder.stop();
} catch {
// Recorder may already be stopping.
}
}
if (activeWebcamRecorder) {
if (activeWebcamRecorder.recorder.state === "recording") {
if (
activeWebcamRecorder.recorder.state === "recording" ||
activeWebcamRecorder.recorder.state === "paused"
) {
try {
activeWebcamRecorder.recorder.stop();
} catch {
Expand All @@ -316,14 +338,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
restarting.current = false;
discardRecordingId.current = null;

if (screenRecorder.current?.recorder.state === "recording") {
if (
screenRecorder.current?.recorder.state === "recording" ||
screenRecorder.current?.recorder.state === "paused"
) {
try {
screenRecorder.current.recorder.stop();
} catch {
// Ignore recorder teardown errors during cleanup.
}
}
if (webcamRecorder.current?.recorder.state === "recording") {
if (
webcamRecorder.current?.recorder.state === "recording" ||
webcamRecorder.current?.recorder.state === "paused"
) {
try {
webcamRecorder.current.recorder.stop();
} catch {
Expand Down Expand Up @@ -519,8 +547,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {

recordingId.current = Date.now();
startTime.current = recordingId.current;
pausedStartedAt.current = null;
pausedDurationMs.current = 0;
allowAutoFinalize.current = true;
setRecording(true);
setPaused(false);
window.electronAPI?.setRecordingState(true);

const activeScreenRecorder = screenRecorder.current;
Expand All @@ -533,10 +564,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (!allowAutoFinalize.current) {
return;
}
const activePauseMs = pausedStartedAt.current
? Date.now() - pausedStartedAt.current
: 0;
finalizeRecording(
activeScreenRecorder,
activeWebcamRecorder ?? null,
Math.max(0, Date.now() - startTime.current),
Math.max(
0,
Date.now() - startTime.current - pausedDurationMs.current - activePauseMs,
),
activeRecordingId,
);
},
Expand All @@ -552,12 +589,40 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
toast.error(errorMsg);
}
setRecording(false);
setPaused(false);
pausedStartedAt.current = null;
pausedDurationMs.current = 0;
screenRecorder.current = null;
webcamRecorder.current = null;
teardownMedia();
}
};

const pauseRecording = () => {
if (screenRecorder.current?.recorder.state === "recording") {
screenRecorder.current.recorder.pause();
if (webcamRecorder.current?.recorder.state === "recording") {
webcamRecorder.current.recorder.pause();
}
pausedStartedAt.current = Date.now();
setPaused(true);
}
};

const resumeRecording = () => {
if (screenRecorder.current?.recorder.state === "paused") {
if (pausedStartedAt.current) {
pausedDurationMs.current += Date.now() - pausedStartedAt.current;
pausedStartedAt.current = null;
}
if (webcamRecorder.current?.recorder.state === "paused") {
webcamRecorder.current.recorder.resume();
}
screenRecorder.current.recorder.resume();
setPaused(false);
}
};

const toggleRecording = () => {
recording ? stopRecording.current() : startRecording();
};
Expand Down Expand Up @@ -603,7 +668,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {

return {
recording,
paused,
toggleRecording,
pauseRecording,
resumeRecording,
restartRecording,
microphoneEnabled,
setMicrophoneEnabled,
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"closeApp": "Close App",
"restartRecording": "Restart recording",
"openVideoFile": "Open video file",
"openProject": "Open project"
"openProject": "Open project",
"pauseRecording": "Pause recording",
"resumeRecording": "Resume recording"
},
"audio": {
"enableSystemAudio": "Enable system audio",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/es/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"closeApp": "Cerrar aplicación",
"restartRecording": "Reiniciar grabación",
"openVideoFile": "Abrir archivo de video",
"openProject": "Abrir proyecto"
"openProject": "Abrir proyecto",
"pauseRecording": "Pausar grabación",
"resumeRecording": "Reanudar grabación"
},
"audio": {
"enableSystemAudio": "Activar audio del sistema",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh-CN/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"closeApp": "关闭应用",
"restartRecording": "重新开始录制",
"openVideoFile": "打开视频文件",
"openProject": "打开项目"
"openProject": "打开项目",
"pauseRecording": "暂停录制",
"resumeRecording": "恢复录制"
},
"audio": {
"enableSystemAudio": "启用系统音频",
Expand Down