diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c51a5fe0..6c17d47a 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -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 }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 0a12b9de..5db60fd4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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() } diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index b6bc1e74..89cd4c93 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -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 diff --git a/electron/native/bin/darwin-arm64/openscreen-native-cursor-monitor b/electron/native/bin/darwin-arm64/openscreen-native-cursor-monitor index c123f707..fdd0bd08 100755 Binary files a/electron/native/bin/darwin-arm64/openscreen-native-cursor-monitor and b/electron/native/bin/darwin-arm64/openscreen-native-cursor-monitor differ diff --git a/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper b/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper index 1391758c..1c5df9e3 100755 Binary files a/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper and b/electron/native/bin/darwin-arm64/openscreen-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-arm64/openscreen-system-cursors b/electron/native/bin/darwin-arm64/openscreen-system-cursors index c36225a8..d6564426 100755 Binary files a/electron/native/bin/darwin-arm64/openscreen-system-cursors and b/electron/native/bin/darwin-arm64/openscreen-system-cursors differ diff --git a/electron/native/bin/darwin-arm64/openscreen-window-list b/electron/native/bin/darwin-arm64/openscreen-window-list index 29f275af..ddea8efe 100755 Binary files a/electron/native/bin/darwin-arm64/openscreen-window-list and b/electron/native/bin/darwin-arm64/openscreen-window-list differ diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 3f5da69b..a603e944 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -12,8 +12,14 @@ #include #include #include +#include static std::atomic g_stopRequested{false}; +static std::atomic g_pauseRequested{false}; +static std::atomic g_resumePending{false}; +static std::atomic g_lastFrameTimestampHns{0}; +static std::atomic g_pauseStartTimestampHns{0}; +static std::atomic 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 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 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 diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp index af9b5bc3..8ae15ad1 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.cpp +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -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(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); std::vector 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((static_cast(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); } diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h index 3b2b76df..a4facf13 100644 --- a/electron/native/wgc-capture/src/wasapi_loopback.h +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -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 capturing_{false}; + std::atomic paused_{false}; + HANDLE outputFile_ = INVALID_HANDLE_VALUE; + DWORD totalDataBytes_ = 0; IMMDeviceEnumerator* enumerator_ = nullptr; IMMDevice* device_ = nullptr; diff --git a/electron/preload.ts b/electron/preload.ts index 55fd26b1..f13bb835 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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); }, diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index e8c2b6a3..398d71bb 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -97,6 +97,36 @@ function resumeRecording( return false; } +async function pauseNativeRecording( + webcamRecorder?: ReturnType | null, + result: { success: boolean } = { success: true }, +): Promise { + if (!result.success) { + return false; + } + + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + + return true; +} + +async function resumeNativeRecording( + webcamRecorder?: ReturnType | null, + result: { success: boolean } = { success: true }, +): Promise { + if (!result.success) { + return false; + } + + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + + return true; +} + function cancelRecording( recorder: ReturnType, 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"])] }; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 10905d11..41ac4a7b 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -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") {