diff --git a/src/hooks/useRecording.ts b/src/hooks/useRecording.ts index 3ecd08e..8649075 100644 --- a/src/hooks/useRecording.ts +++ b/src/hooks/useRecording.ts @@ -10,10 +10,15 @@ import { ConferenceClient } from 'red5pro-conference-sdk'; import { ConferenceEvents } from 'red5pro-conference-sdk'; import { MediabunnyRecorder } from '../utils/MediabunnyRecorder'; import { createCompositeStream } from '../utils/compositeStream'; +import { withTimeout, TimeoutError } from '../utils/withTimeout'; import JSZip from 'jszip'; import { S3Client } from '@aws-sdk/client-s3'; import { Upload } from '@aws-sdk/lib-storage'; +// Some browsers (notably Safari) can hang indefinitely while flushing WebCodecs +// encoders on stop(); cap the wait so the UI never gets stuck showing "recording". +const RECORDER_STOP_TIMEOUT_MS = 15000; + // Type definitions type MessageVariant = 'info' | 'success' | 'error' | 'warning'; @@ -741,12 +746,20 @@ export const useRecording = ( } try { - const blob = await mediabunnyRecorderRef.current.stop(); + const blob = await withTimeout( + mediabunnyRecorderRef.current.stop(), + RECORDER_STOP_TIMEOUT_MS, + 'Timed out stopping local recording', + ); // Stop the local-only recorder alongside the composite one if (localOnlyRecorderRef.current) { try { - const localOnlyBlob = await localOnlyRecorderRef.current.stop(); + const localOnlyBlob = await withTimeout( + localOnlyRecorderRef.current.stop(), + RECORDER_STOP_TIMEOUT_MS, + 'Timed out stopping local-only recording', + ); if (localOnlyBlob) { localOnlyRecordedSegmentsRef.current.push(localOnlyBlob); } @@ -808,7 +821,12 @@ export const useRecording = ( currentRecordingStreamRef.current = null; localStreamRef.current = null; if (displayMessageRef.current) { - displayMessageRef.current('Failed to stop local recording', 'error'); + displayMessageRef.current( + error instanceof TimeoutError + ? 'Stopping local recording took too long and was aborted' + : 'Failed to stop local recording', + 'error', + ); } return null; } diff --git a/src/hooks/useVirtualBackground.ts b/src/hooks/useVirtualBackground.ts index 437ee71..69d26dd 100644 --- a/src/hooks/useVirtualBackground.ts +++ b/src/hooks/useVirtualBackground.ts @@ -4,6 +4,13 @@ import { useState, useCallback, useEffect, useRef, MutableRefObject } from 'reac import { getVirtualBackgroundConfigs } from '../utils/conferenceConfig'; import { VirtualBackgroundTypes } from 'red5pro-conference-sdk'; import log from 'loglevel'; +import { withTimeout } from '../utils/withTimeout'; + +// Some browsers (notably Safari, which falls back to an OffscreenCanvas/WASM +// segmentation pipeline instead of insertable streams) can hang indefinitely +// when initializing or applying a virtual background. Cap the wait so the UI +// never gets stuck reporting the effect as enabled when it isn't working. +const VIRTUAL_BACKGROUND_TIMEOUT_MS = 15000; // Type definitions type VirtualBackgroundType = 'none' | 'blur' | 'slight-blur' | 'color' | 'image'; @@ -152,7 +159,11 @@ export const useVirtualBackground = ( const status = conferenceClientRef.current.getVirtualBackgroundStatus(); if (!status.isInitialized) { - await conferenceClientRef.current.initializeVirtualBackground(); + await withTimeout( + conferenceClientRef.current.initializeVirtualBackground(), + VIRTUAL_BACKGROUND_TIMEOUT_MS, + 'Timed out initializing virtual background', + ); log.log('Virtual background initialized'); setIsVirtualBackgroundInitialized(true); } @@ -204,7 +215,11 @@ export const useVirtualBackground = ( // Handle disable case if (type === VirtualBackgroundTypes.NONE) { - await conferenceClientRef.current.disableVirtualBackground(); + await withTimeout( + conferenceClientRef.current.disableVirtualBackground(), + VIRTUAL_BACKGROUND_TIMEOUT_MS, + 'Timed out disabling virtual background', + ); setSelectedBackgroundMode(''); return true; } @@ -233,7 +248,11 @@ export const useVirtualBackground = ( } // Apply the background - await conferenceClientRef.current[action](config.type, config.options); + await withTimeout( + conferenceClientRef.current[action](config.type, config.options), + VIRTUAL_BACKGROUND_TIMEOUT_MS, + `Timed out applying virtual background (${action})`, + ); setSelectedBackgroundMode(type); log.log(`Virtual background ${action} successful: ${type}`); @@ -241,6 +260,10 @@ export const useVirtualBackground = ( } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; log.error('Failed to handle background replacement:', error); + // Ensure the UI never reports the effect as enabled when the apply call + // hung/failed and the SDK never actually produced the processed frame. + setIsVirtualBackgroundEnabled(false); + setSelectedBackgroundMode(''); if (showWarningRef.current) { showWarningRef.current('Failed to apply virtual background: ' + errorMessage); } diff --git a/src/utils/MediabunnyRecorder.ts b/src/utils/MediabunnyRecorder.ts index 2c13937..ebed4a3 100644 --- a/src/utils/MediabunnyRecorder.ts +++ b/src/utils/MediabunnyRecorder.ts @@ -149,9 +149,6 @@ export class MediabunnyRecorder { this.recordedBlob = new Blob([this.target.buffer], { type: 'video/mp4' }); } - this._isRecording = false; - this._isPaused = false; - if (this.onstop && this.recordedBlob) { this.onstop(this.recordedBlob); } @@ -163,6 +160,10 @@ export class MediabunnyRecorder { } throw error; } finally { + // Reset regardless of outcome so callers never see a stuck "recording" state + // if finalize() throws or is abandoned via a timeout. + this._isRecording = false; + this._isPaused = false; this.cleanup(); } } diff --git a/src/utils/withTimeout.ts b/src/utils/withTimeout.ts new file mode 100644 index 0000000..42c5197 --- /dev/null +++ b/src/utils/withTimeout.ts @@ -0,0 +1,27 @@ +/** + * Races a promise against a timeout so a hung Safari media API call + * (WebCodecs encoder flush, WASM/OffscreenCanvas segmentation, etc.) + * can't leave callers awaiting forever. + */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +export function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new TimeoutError(message)), ms); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +}