Pause native screen capture across platforms

This commit is contained in:
webadderall committed 2026-03-18 18:36:25 +11:00
1 parent d5f14f9b16
commit de1074cdff
13 files changed
+378 -42

No files matched your search

+10
View File
@@ -45,6 +45,16 @@ interface Window {
message?: string;
error?: string;
}>;
pauseNativeScreenRecording: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
resumeNativeScreenRecording: () => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
startFfmpegRecording: (
source: any,
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
+91
View File
@@ -75,6 +75,7 @@ let nativeCaptureOutputBuffer = ''
let nativeCaptureTargetPath: string | null = null
let nativeCaptureStopRequested = false
let nativeCaptureMicrophonePath: string | null = null
let nativeCapturePaused = false
let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null
let nativeCursorMonitorOutputBuffer = ''
let wgcCaptureProcess: ChildProcessWithoutNullStreams | null = null
@@ -82,6 +83,7 @@ let wgcCaptureOutputBuffer = ''
let wgcCaptureTargetPath: string | null = null
let wgcScreenRecordingActive = false
let wgcCaptureStopRequested = false
let wgcCapturePaused = false
let wgcSystemAudioPath: string | null = null
let wgcMicAudioPath: string | null = null
let wgcPendingVideoPath: string | null = null
@@ -122,6 +124,7 @@ export function killWgcCaptureProcess() {
wgcScreenRecordingActive = false
nativeScreenRecordingActive = false
wgcCaptureStopRequested = false
wgcCapturePaused = false
wgcSystemAudioPath = null
wgcMicAudioPath = null
wgcPendingVideoPath = null
@@ -2137,6 +2140,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
wgcCaptureOutputBuffer = ''
wgcCaptureTargetPath = outputPath
wgcCaptureStopRequested = false
wgcCapturePaused = false
wgcCaptureProcess = spawn(exePath, [JSON.stringify(config)], {
cwd: recordingsDir,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -2162,6 +2166,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
wgcCaptureProcess = null
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
wgcCapturePaused = false
return {
success: false,
message: 'Failed to start WGC capture',
@@ -2240,6 +2245,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
nativeCaptureTargetPath = outputPath
nativeCaptureMicrophonePath = microphoneOutputPath
nativeCaptureStopRequested = false
nativeCapturePaused = false
nativeCaptureProcess = spawn(helperPath, [JSON.stringify(config)], {
cwd: recordingsDir,
stdio: ['pipe', 'pipe', 'pipe'],
@@ -2268,6 +2274,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
nativeCaptureTargetPath = null
nativeCaptureMicrophonePath = null
nativeCaptureStopRequested = false
nativeCapturePaused = false
return {
success: false,
message: 'Failed to start native ScreenCaptureKit recording',
@@ -2294,6 +2301,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
nativeScreenRecordingActive = false
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
wgcCapturePaused = false
const finalVideoPath = preferredVideoPath ?? tempVideoPath
if (tempVideoPath !== finalVideoPath) {
@@ -2310,6 +2318,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
wgcCaptureProcess = null
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
wgcCapturePaused = false
wgcSystemAudioPath = null
wgcMicAudioPath = null
wgcPendingVideoPath = null
@@ -2356,6 +2365,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
nativeCaptureTargetPath = null
nativeCaptureMicrophonePath = null
nativeCaptureStopRequested = false
nativeCapturePaused = false
const finalVideoPath = preferredVideoPath ?? tempVideoPath
if (tempVideoPath !== finalVideoPath) {
@@ -2380,6 +2390,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
nativeCaptureTargetPath = null
nativeCaptureMicrophonePath = null
nativeCaptureStopRequested = false
nativeCapturePaused = false
// Try to recover: if the target file exists on disk, finalize with it
if (fallbackPath) {
@@ -2400,6 +2411,86 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
}
})
ipcMain.handle('pause-native-screen-recording', async () => {
if (process.platform === 'win32') {
if (!wgcScreenRecordingActive || !wgcCaptureProcess) {
return { success: false, message: 'No WGC screen recording is active.' }
}
if (wgcCapturePaused) {
return { success: true }
}
try {
wgcCaptureProcess.stdin.write('pause\n')
wgcCapturePaused = true
return { success: true }
} catch (error) {
return { success: false, message: 'Failed to pause WGC capture', error: String(error) }
}
}
if (process.platform !== 'darwin') {
return { success: false, message: 'Native screen recording is only available on macOS.' }
}
if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
return { success: false, message: 'No native screen recording is active.' }
}
if (nativeCapturePaused) {
return { success: true }
}
try {
nativeCaptureProcess.stdin.write('pause\n')
nativeCapturePaused = true
return { success: true }
} catch (error) {
return { success: false, message: 'Failed to pause native screen recording', error: String(error) }
}
})
ipcMain.handle('resume-native-screen-recording', async () => {
if (process.platform === 'win32') {
if (!wgcScreenRecordingActive || !wgcCaptureProcess) {
return { success: false, message: 'No WGC screen recording is active.' }
}
if (!wgcCapturePaused) {
return { success: true }
}
try {
wgcCaptureProcess.stdin.write('resume\n')
wgcCapturePaused = false
return { success: true }
} catch (error) {
return { success: false, message: 'Failed to resume WGC capture', error: String(error) }
}
}
if (process.platform !== 'darwin') {
return { success: false, message: 'Native screen recording is only available on macOS.' }
}
if (!nativeScreenRecordingActive || !nativeCaptureProcess) {
return { success: false, message: 'No native screen recording is active.' }
}
if (!nativeCapturePaused) {
return { success: true }
}
try {
nativeCaptureProcess.stdin.write('resume\n')
nativeCapturePaused = false
return { success: true }
} catch (error) {
return { success: false, message: 'Failed to resume native screen recording', error: String(error) }
}
})
ipcMain.handle('get-system-cursor-assets', async () => {
try {
return { success: true, cursors: await getSystemCursorAssets() }
+101 -9
View File
@@ -28,7 +28,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
private var firstPrimaryAudioSampleTime: CMTime?
private var firstMicrophoneSampleTime: CMTime?
private var lastSampleBuffer: CMSampleBuffer?
private var lastVideoPresentationTime: CMTime = .zero
private var lastVideoDuration: CMTime = .zero
private var isRecording = false
private var isPaused = false
private var pauseStartedHostTime: CMTime?
private var pendingResumeAdjustment = false
private var accumulatedPausedDuration: CMTime = .zero
private var sessionStarted = false
private var frameCount = 0
private var outputURL: URL?
@@ -230,8 +236,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
assetWriter.startSession(atSourceTime: .zero)
sessionStarted = true
isRecording = true
isPaused = false
pauseStartedHostTime = nil
pendingResumeAdjustment = false
accumulatedPausedDuration = .zero
frameCount = 0
firstSampleTime = .zero
lastVideoPresentationTime = .zero
lastVideoDuration = .zero
startWindowValidationIfNeeded()
print("Recording started")
fflush(stdout)
@@ -245,8 +257,22 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return try await finishCapture()
}
func pauseCapture() {
guard isRecording, !isPaused else { return }
isPaused = true
pauseStartedHostTime = CMClockGetTime(CMClockGetHostTimeClock())
pendingResumeAdjustment = false
}
func resumeCapture() {
guard isRecording, isPaused else { return }
isPaused = false
pendingResumeAdjustment = true
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of outputType: SCStreamOutputType) {
guard sessionStarted, sampleBuffer.isValid, isRecording else { return }
guard let presentationTime = adjustedPresentationTime(for: sampleBuffer, outputType: outputType) else { return }
if outputType == .screen {
guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
@@ -263,11 +289,12 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
firstSampleTime = sampleBuffer.presentationTimeStamp
}
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
lastSampleBuffer = sampleBuffer
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
videoInput.append(retimedSampleBuffer)
lastVideoPresentationTime = presentationTime
lastVideoDuration = sampleBuffer.duration
frameCount += 1
}
return
@@ -275,15 +302,15 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
if outputType == .audio {
guard primaryAudioSource == .system, let audioInput else { return }
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
return
}
if outputType.rawValue == microphoneOutputTypeRawValue {
if writesMicrophoneToSeparateTrack, let microphoneOnlyInput {
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: microphoneOnlyInput, firstSampleTime: &firstMicrophoneSampleTime, presentationTime: presentationTime)
} else if primaryAudioSource == .microphone, let audioInput {
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime)
appendAudioSampleBuffer(sampleBuffer, to: audioInput, firstSampleTime: &firstPrimaryAudioSampleTime, presentationTime: presentationTime)
}
return
}
@@ -312,7 +339,7 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
stream = nil
if let originalBuffer = lastSampleBuffer, let videoInput = videoInput {
let additionalTime = CMTime(seconds: ProcessInfo.processInfo.systemUptime, preferredTimescale: 600) - firstSampleTime
let additionalTime = lastVideoPresentationTime + frameDuration(for: originalBuffer)
let timing = CMSampleTimingInfo(duration: originalBuffer.duration, presentationTimeStamp: additionalTime, decodeTimeStamp: originalBuffer.decodeTimeStamp)
if let additionalSampleBuffer = try? CMSampleBuffer(copying: originalBuffer, withNewTiming: [timing]) {
videoInput.append(additionalSampleBuffer)
@@ -340,7 +367,13 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
firstPrimaryAudioSampleTime = nil
firstMicrophoneSampleTime = nil
lastSampleBuffer = nil
lastVideoPresentationTime = .zero
lastVideoDuration = .zero
frameCount = 0
isPaused = false
pauseStartedHostTime = nil
pendingResumeAdjustment = false
accumulatedPausedDuration = .zero
capturesSystemAudio = false
capturesMicrophone = false
writesMicrophoneToSeparateTrack = false
@@ -348,16 +381,53 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
return path
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?) {
private func adjustedPresentationTime(for sampleBuffer: CMSampleBuffer, outputType: SCStreamOutputType) -> CMTime? {
if isPaused {
return nil
}
let sampleTime = sampleBuffer.presentationTimeStamp
if pendingResumeAdjustment, let pauseStartedHostTime {
let pauseGap = sampleTime - pauseStartedHostTime
if pauseGap > .zero {
accumulatedPausedDuration = accumulatedPausedDuration + pauseGap
}
self.pauseStartedHostTime = nil
pendingResumeAdjustment = false
}
if outputType == .screen {
if firstSampleTime == .zero {
firstSampleTime = sampleTime
}
return max(.zero, sampleTime - firstSampleTime - accumulatedPausedDuration)
}
return sampleTime - accumulatedPausedDuration
}
private func frameDuration(for sampleBuffer: CMSampleBuffer) -> CMTime {
if sampleBuffer.duration.isValid && sampleBuffer.duration > .zero {
return sampleBuffer.duration
}
if lastVideoDuration.isValid && lastVideoDuration > .zero {
return lastVideoDuration
}
return CMTime(value: 1, timescale: CMTimeScale(targetCaptureFPS))
}
private func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer, to input: AVAssetWriterInput, firstSampleTime: inout CMTime?, presentationTime: CMTime) {
guard input.isReadyForMoreMediaData else { return }
if firstSampleTime == nil {
firstSampleTime = sampleBuffer.presentationTimeStamp
firstSampleTime = presentationTime
}
guard let firstSampleTime else { return }
let presentationTime = sampleBuffer.presentationTimeStamp - firstSampleTime
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: presentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
let relativePresentationTime = max(.zero, presentationTime - firstSampleTime)
let timing = CMSampleTimingInfo(duration: sampleBuffer.duration, presentationTimeStamp: relativePresentationTime, decodeTimeStamp: sampleBuffer.decodeTimeStamp)
if let retimedSampleBuffer = try? CMSampleBuffer(copying: sampleBuffer, withNewTiming: [timing]) {
input.append(retimedSampleBuffer)
}
@@ -457,6 +527,18 @@ final class RecorderService {
}
}
func pause() {
queue.async {
self.recorder.pauseCapture()
}
}
func resume() {
queue.async {
self.recorder.resumeCapture()
}
}
func waitUntilFinished() {
completionGroup.wait()
}
@@ -478,6 +560,16 @@ service.start(configJSON: CommandLine.arguments[1])
DispatchQueue.global(qos: .utility).async {
while let input = readLine(strippingNewline: true)?.lowercased() {
if input == "pause" {
service.pause()
continue
}
if input == "resume" {
service.resume()
continue
}
if input == "stop" {
service.stop()
break
+46 -3
View File
@@ -12,8 +12,14 @@
#include <atomic>
#include <mutex>
#include <condition_variable>
#include <chrono>
static std::atomic<bool> g_stopRequested{false};
static std::atomic<bool> g_pauseRequested{false};
static std::atomic<bool> g_resumePending{false};
static std::atomic<int64_t> g_lastFrameTimestampHns{0};
static std::atomic<int64_t> g_pauseStartTimestampHns{0};
static std::atomic<int64_t> g_accumulatedPausedHns{0};
static std::mutex g_stopMutex;
static std::condition_variable g_stopCv;
@@ -140,6 +146,18 @@ static void stdinListenerThread() {
line.pop_back();
}
if (line == "pause") {
g_pauseRequested = true;
g_pauseStartTimestampHns = g_lastFrameTimestampHns.load();
continue;
}
if (line == "resume") {
g_pauseRequested = false;
g_resumePending = true;
continue;
}
if (line == "stop") {
g_stopRequested = true;
g_stopCv.notify_all();
@@ -209,8 +227,25 @@ int main(int argc, char* argv[]) {
// Set up frame callback
std::atomic<int64_t> frameCount{0};
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
g_lastFrameTimestampHns = timestampHns;
if (g_stopRequested) return;
if (encoder.writeFrame(texture, timestampHns)) {
if (g_pauseRequested) return;
int64_t adjustedTimestampHns = timestampHns;
if (g_resumePending.exchange(false)) {
const int64_t pauseStart = g_pauseStartTimestampHns.load();
if (pauseStart > 0 && timestampHns > pauseStart) {
g_accumulatedPausedHns += (timestampHns - pauseStart);
}
}
adjustedTimestampHns -= g_accumulatedPausedHns.load();
if (adjustedTimestampHns < 0) {
adjustedTimestampHns = 0;
}
if (encoder.writeFrame(texture, adjustedTimestampHns)) {
frameCount++;
}
});
@@ -258,9 +293,17 @@ int main(int argc, char* argv[]) {
std::cout.flush();
// Wait for stop signal
{
while (!g_stopRequested) {
if (g_pauseRequested) {
if (audioActive) loopback.pause();
if (micActive) micCapture.pause();
} else {
if (audioActive) loopback.resume();
if (micActive) micCapture.resume();
}
std::unique_lock<std::mutex> lock(g_stopMutex);
g_stopCv.wait(lock, [] { return g_stopRequested.load(); });
g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); });
}
// Stop capture and finalize
@@ -125,19 +125,61 @@ bool WasapiCapture::initializeCommon() {
}
bool WasapiCapture::start() {
if (capturing_) return true;
outputFile_ = CreateFileA(
outputPath_.c_str(), GENERIC_WRITE, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (outputFile_ == INVALID_HANDLE_VALUE) {
std::cerr << "ERROR: Cannot create audio output file" << std::endl;
return false;
}
totalDataBytes_ = 0;
writeWavHeader(outputFile_, 0);
HRESULT hr = audioClient_->Start();
if (FAILED(hr)) return false;
if (FAILED(hr)) {
CloseHandle(outputFile_);
outputFile_ = INVALID_HANDLE_VALUE;
return false;
}
capturing_ = true;
paused_ = false;
thread_ = std::thread(&WasapiCapture::captureThread, this);
return true;
}
bool WasapiCapture::pause() {
if (!capturing_ || paused_) return true;
paused_ = true;
return audioClient_ ? SUCCEEDED(audioClient_->Stop()) : false;
}
bool WasapiCapture::resume() {
if (!capturing_ || !paused_) return true;
HRESULT hr = audioClient_ ? audioClient_->Start() : E_FAIL;
if (FAILED(hr)) return false;
paused_ = false;
return true;
}
void WasapiCapture::stop() {
if (!capturing_) return;
capturing_ = false;
if (thread_.joinable()) thread_.join();
if (audioClient_) audioClient_->Stop();
if (outputFile_ != INVALID_HANDLE_VALUE) {
SetFilePointer(outputFile_, 0, nullptr, FILE_BEGIN);
writeWavHeader(outputFile_, totalDataBytes_);
CloseHandle(outputFile_);
outputFile_ = INVALID_HANDLE_VALUE;
}
paused_ = false;
}
static int16_t floatToInt16(float v) {
@@ -179,22 +221,16 @@ void WasapiCapture::captureThread() {
reinterpret_cast<WAVEFORMATEXTENSIBLE*>(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
std::vector<int16_t> pcmBuffer;
HANDLE tmpFile = CreateFileA(
outputPath_.c_str(), GENERIC_WRITE, 0, nullptr,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (tmpFile == INVALID_HANDLE_VALUE) {
std::cerr << "ERROR: Cannot create audio output file" << std::endl;
return;
}
writeWavHeader(tmpFile, 0);
DWORD totalDataBytes = 0;
DWORD sleepMs = static_cast<DWORD>((static_cast<double>(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0);
if (sleepMs < 5) sleepMs = 5;
while (capturing_) {
if (paused_) {
Sleep(10);
continue;
}
Sleep(sleepMs);
UINT32 packetLength = 0;
@@ -228,15 +264,11 @@ void WasapiCapture::captureThread() {
DWORD bytesToWrite = totalSamples * sizeof(int16_t);
DWORD written;
WriteFile(tmpFile, pcmBuffer.data(), bytesToWrite, &written, nullptr);
totalDataBytes += written;
WriteFile(outputFile_, pcmBuffer.data(), bytesToWrite, &written, nullptr);
totalDataBytes_ += written;
hr = captureClient_->GetNextPacketSize(&packetLength);
if (FAILED(hr)) break;
}
}
SetFilePointer(tmpFile, 0, nullptr, FILE_BEGIN);
writeWavHeader(tmpFile, totalDataBytes);
CloseHandle(tmpFile);
}
@@ -16,6 +16,8 @@ public:
bool initializeLoopback(const std::string& outputPath);
bool initializeMic(const std::string& outputPath, const std::string& deviceName = "");
bool start();
bool pause();
bool resume();
void stop();
private:
@@ -27,6 +29,9 @@ private:
std::string outputPath_;
std::thread thread_;
std::atomic<bool> capturing_{false};
std::atomic<bool> paused_{false};
HANDLE outputFile_ = INVALID_HANDLE_VALUE;
DWORD totalDataBytes_ = 0;
IMMDeviceEnumerator* enumerator_ = nullptr;
IMMDevice* device_ = nullptr;
+6
View File
@@ -51,6 +51,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
stopNativeScreenRecording: () => {
return ipcRenderer.invoke("stop-native-screen-recording");
},
pauseNativeScreenRecording: () => {
return ipcRenderer.invoke("pause-native-screen-recording");
},
resumeNativeScreenRecording: () => {
return ipcRenderer.invoke("resume-native-screen-recording");
},
startFfmpegRecording: (source: any) => {
return ipcRenderer.invoke("start-ffmpeg-recording", source);
},
+45 -3
View File
@@ -97,6 +97,36 @@ function resumeRecording(
return false;
}
async function pauseNativeRecording(
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
result: { success: boolean } = { success: true },
): Promise<boolean> {
if (!result.success) {
return false;
}
if (webcamRecorder?.state === "recording") {
webcamRecorder.pause();
}
return true;
}
async function resumeNativeRecording(
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | null,
result: { success: boolean } = { success: true },
): Promise<boolean> {
if (!result.success) {
return false;
}
if (webcamRecorder?.state === "paused") {
webcamRecorder.resume();
}
return true;
}
function cancelRecording(
recorder: ReturnType<typeof createMockMediaRecorder>,
isNativeRecording: boolean,
@@ -413,18 +443,30 @@ describe("useScreenRecorder state machine", () => {
expect(webcam.state).toBe("inactive");
});
it("native recording: webcam pauses/resumes while screen keeps capturing", () => {
it("native recording pauses webcam only after native pause succeeds", async () => {
const webcam = createMockMediaRecorder("recording");
pauseRecording(recorder, true, false, true, webcam);
const pausedResult = await pauseNativeRecording(webcam);
expect(pausedResult).toBe(true);
expect(webcam.state).toBe("paused");
expect(recorder.pause).not.toHaveBeenCalled();
resumeRecording(recorder, true, true, true, webcam);
const resumedResult = await resumeNativeRecording(webcam);
expect(resumedResult).toBe(true);
expect(webcam.state).toBe("recording");
expect(recorder.resume).not.toHaveBeenCalled();
});
it("native recording leaves webcam state alone when native pause fails", async () => {
const webcam = createMockMediaRecorder("recording");
const pausedResult = await pauseNativeRecording(webcam, { success: false });
expect(pausedResult).toBe(false);
expect(webcam.state).toBe("recording");
expect(webcam.pause).not.toHaveBeenCalled();
});
it("cancel discards both screen and webcam recordings", () => {
const webcam = createMockMediaRecorder("recording");
const chunks = { current: [new Blob(["screen"])] };
+24 -9
View File
@@ -751,11 +751,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const pauseRecording = useCallback(() => {
if (!recording || paused) return;
if (nativeScreenRecording.current) {
// Native captures cannot truly pause, but we pause the timer/UI and webcam
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
setPaused(true);
void (async () => {
const result = await window.electronAPI.pauseNativeScreenRecording();
if (!result.success) {
console.error("Failed to pause native screen recording:", result.error ?? result.message);
return;
}
if (webcamRecorder.current?.state === "recording") {
webcamRecorder.current.pause();
}
setPaused(true);
})();
return;
}
if (mediaRecorder.current?.state === "recording") {
@@ -770,10 +777,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const resumeRecording = useCallback(() => {
if (!recording || !paused) return;
if (nativeScreenRecording.current) {
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
setPaused(false);
void (async () => {
const result = await window.electronAPI.resumeNativeScreenRecording();
if (!result.success) {
console.error("Failed to resume native screen recording:", result.error ?? result.message);
return;
}
if (webcamRecorder.current?.state === "paused") {
webcamRecorder.current.resume();
}
setPaused(false);
})();
return;
}
if (mediaRecorder.current?.state === "paused") {