Skip to content

Commit e48f225

Browse files
authored
Merge branch 'epic/AC-DC' into TTS-273
2 parents 8099982 + 655dc50 commit e48f225

8 files changed

Lines changed: 142 additions & 65 deletions

File tree

src/Components/ExternalStreamsDrawer.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import {
1313
ListItemText,
1414
ListItemSecondaryAction,
1515
CircularProgress,
16+
Tooltip,
1617
} from '@mui/material';
1718
import { useTranslation } from 'react-i18next';
1819
import CloseDrawerButton from './DrawerButton';
1920
import { getRed5DrawerStyle } from '../styles/themeUtil';
2021
import { SvgIcon } from './SvgIcon';
2122
import { ExternalStream } from '../hooks/useExternalStreams';
2223

23-
import { parseMetaData } from '../utils/utils';
24+
import { parseMetaData, truncateText } from '../utils/utils';
2425
import { MetaDataKeys } from '../constants/metaDataKeys';
2526

2627
interface ExternalStreamsDrawerProps {
@@ -179,10 +180,12 @@ const ExternalStreamsDrawer = React.memo<ExternalStreamsDrawerProps>((props) =>
179180
},
180181
}}
181182
>
182-
<ListItemText
183-
primary={stream.streamName}
184-
secondary={isJoined ? t('Joined') : null}
185-
/>
183+
<Tooltip title={stream.streamName} placement="top">
184+
<ListItemText
185+
primary={truncateText(stream.streamName, 8)}
186+
secondary={isJoined ? t('Joined') : null}
187+
/>
188+
</Tooltip>
186189
<ListItemSecondaryAction>
187190
<Button
188191
variant={isJoined ? 'outlined' : 'contained'}

src/Components/Footer/Components/OptionButton.tsx

Lines changed: 52 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import {
2020
Box,
2121
Chip,
2222
Typography,
23+
Collapse,
24+
RadioGroup,
25+
Radio,
2326
} from '@mui/material';
2427
import { useTranslation } from 'react-i18next';
2528
import GeneralSettingsDialog from './GeneralSettingsDialog.tsx';
@@ -138,9 +141,6 @@ function OptionButton(props: OptionButtonProps) {
138141
const [diagnosticDialogOpen, setDiagnosticDialogOpen] = React.useState<boolean>(false);
139142
const [generalSettingsDialogOpen, setGeneralSettingsDialogOpen] = React.useState<boolean>(false);
140143
const [recordingModalOpen, setRecordingModalOpen] = React.useState<boolean>(false);
141-
const [recordSeparately, setRecordSeparately] = React.useState<boolean>(() => {
142-
return localStorage.getItem('recordSeparately') === 'true';
143-
});
144144
const [localRecordingChecked, setLocalRecordingChecked] = React.useState<boolean>(() => {
145145
return localStorage.getItem('localRecordingChecked') === 'true';
146146
});
@@ -149,10 +149,9 @@ function OptionButton(props: OptionButtonProps) {
149149
return localStorage.getItem('serverSideRecordingChecked') === 'true';
150150
},
151151
);
152-
153-
React.useEffect(() => {
154-
localStorage.setItem('recordSeparately', String(recordSeparately));
155-
}, [recordSeparately]);
152+
const [serverRecordingMode, setServerRecordingMode] = React.useState<'grid' | 'separate'>(() => {
153+
return localStorage.getItem('serverRecordingMode') === 'separate' ? 'separate' : 'grid';
154+
});
156155

157156
React.useEffect(() => {
158157
localStorage.setItem('localRecordingChecked', String(localRecordingChecked));
@@ -161,6 +160,10 @@ function OptionButton(props: OptionButtonProps) {
161160
React.useEffect(() => {
162161
localStorage.setItem('serverSideRecordingChecked', String(serverSideRecordingChecked));
163162
}, [serverSideRecordingChecked]);
163+
164+
React.useEffect(() => {
165+
localStorage.setItem('serverRecordingMode', serverRecordingMode);
166+
}, [serverRecordingMode]);
164167
const [_hovered, setHovered] = React.useState<boolean>(false);
165168
const theme = useTheme();
166169
const themeContext = React.useContext(ThemeContext);
@@ -294,13 +297,8 @@ function OptionButton(props: OptionButtonProps) {
294297
};
295298

296299
const handleRecordingConfirm = (): void => {
297-
// Recording each participant separately is itself a server-side capability,
298-
// so it must enable server recording even if "Cloud recording" wasn't checked.
299-
props?.startRecord?.(
300-
recordSeparately,
301-
serverSideRecordingChecked || recordSeparately,
302-
localRecordingChecked,
303-
);
300+
const recordSeparately = serverSideRecordingChecked && serverRecordingMode === 'separate';
301+
props?.startRecord?.(recordSeparately, serverSideRecordingChecked, localRecordingChecked);
304302
if (localRecordingChecked && props.startLocalRecording) {
305303
props.startLocalRecording();
306304
}
@@ -423,49 +421,55 @@ function OptionButton(props: OptionButtonProps) {
423421
label={
424422
<Box sx={{ pt: 1 }}>
425423
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
426-
<Typography fontWeight={600}>{t('Grid recording')}</Typography>
424+
<Typography fontWeight={600}>{t('Server-side recording')}</Typography>
427425
<Chip label={t('SERVER-SIDE')} size="small" />
428426
</Box>
429427
<Typography variant="body2" color="text.secondary">
430428
{t(
431-
'Records the meeting grid view as a single MP4 file, stored on Red5 Cloud. Best for sharing, long sessions, and high quality regardless of your connection.',
429+
'Recorded on Red5 Cloud as an MP4 file, independent of your device and connection. Best for sharing and long sessions.',
432430
)}
433431
</Typography>
434432
</Box>
435433
}
436434
/>
437-
</Box>
438-
<Box
439-
sx={{
440-
border: '1px solid',
441-
borderColor: recordSeparately ? 'error.main' : 'divider',
442-
borderRadius: 2,
443-
p: 1,
444-
mb: 1.5,
445-
}}
446-
>
447-
<FormControlLabel
448-
sx={{ alignItems: 'flex-start', width: '100%', m: 0 }}
449-
control={
450-
<Checkbox
451-
checked={recordSeparately}
452-
onChange={(e) => setRecordSeparately(e.target.checked)}
435+
<Collapse in={serverSideRecordingChecked}>
436+
<RadioGroup
437+
value={serverRecordingMode}
438+
onChange={(e) => setServerRecordingMode(e.target.value as 'grid' | 'separate')}
439+
sx={{ pl: 4, pr: 1, pb: 0.5 }}
440+
>
441+
<FormControlLabel
442+
value="grid"
443+
control={<Radio size="small" />}
444+
sx={{ alignItems: 'flex-start', width: '100%', m: 0, mt: 1 }}
445+
label={
446+
<Box>
447+
<Typography fontWeight={500}>{t('Grid recording')}</Typography>
448+
<Typography variant="body2" color="text.secondary">
449+
{t('Records the meeting grid view as a single MP4 file.')}
450+
</Typography>
451+
</Box>
452+
}
453453
/>
454-
}
455-
label={
456-
<Box sx={{ pt: 1 }}>
457-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
458-
<Typography fontWeight={600}>{t('Record participants separately')}</Typography>
459-
<Chip label={t('SERVER-SIDE')} size="small" />
460-
</Box>
461-
<Typography variant="body2" color="text.secondary">
462-
{t(
463-
"Records each participant's audio and video as an independent MP4 file, stored on Red5 Cloud.",
464-
)}
465-
</Typography>
466-
</Box>
467-
}
468-
/>
454+
<FormControlLabel
455+
value="separate"
456+
control={<Radio size="small" />}
457+
sx={{ alignItems: 'flex-start', width: '100%', m: 0, mt: 1.5 }}
458+
label={
459+
<Box>
460+
<Typography fontWeight={500}>
461+
{t('Record participants separately')}
462+
</Typography>
463+
<Typography variant="body2" color="text.secondary">
464+
{t(
465+
"Records each participant's audio and video as an independent MP4 file.",
466+
)}
467+
</Typography>
468+
</Box>
469+
}
470+
/>
471+
</RadioGroup>
472+
</Collapse>
469473
</Box>
470474
<Box
471475
sx={{
@@ -504,7 +508,7 @@ function OptionButton(props: OptionButtonProps) {
504508
<Button
505509
onClick={handleRecordingConfirm}
506510
variant="contained"
507-
disabled={!serverSideRecordingChecked && !recordSeparately && !localRecordingChecked}
511+
disabled={!serverSideRecordingChecked && !localRecordingChecked}
508512
>
509513
{t('Start Recording')}
510514
</Button>

src/hooks/useRecording.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ import { ConferenceClient } from 'red5pro-conference-sdk';
1010
import { ConferenceEvents } from 'red5pro-conference-sdk';
1111
import { MediabunnyRecorder } from '../utils/MediabunnyRecorder';
1212
import { createCompositeStream } from '../utils/compositeStream';
13+
import { withTimeout, TimeoutError } from '../utils/withTimeout';
1314
import JSZip from 'jszip';
1415
import { S3Client } from '@aws-sdk/client-s3';
1516
import { Upload } from '@aws-sdk/lib-storage';
1617

18+
// Some browsers (notably Safari) can hang indefinitely while flushing WebCodecs
19+
// encoders on stop(); cap the wait so the UI never gets stuck showing "recording".
20+
const RECORDER_STOP_TIMEOUT_MS = 15000;
21+
1722
// Type definitions
1823
type MessageVariant = 'info' | 'success' | 'error' | 'warning';
1924

@@ -741,12 +746,20 @@ export const useRecording = (
741746
}
742747

743748
try {
744-
const blob = await mediabunnyRecorderRef.current.stop();
749+
const blob = await withTimeout(
750+
mediabunnyRecorderRef.current.stop(),
751+
RECORDER_STOP_TIMEOUT_MS,
752+
'Timed out stopping local recording',
753+
);
745754

746755
// Stop the local-only recorder alongside the composite one
747756
if (localOnlyRecorderRef.current) {
748757
try {
749-
const localOnlyBlob = await localOnlyRecorderRef.current.stop();
758+
const localOnlyBlob = await withTimeout(
759+
localOnlyRecorderRef.current.stop(),
760+
RECORDER_STOP_TIMEOUT_MS,
761+
'Timed out stopping local-only recording',
762+
);
750763
if (localOnlyBlob) {
751764
localOnlyRecordedSegmentsRef.current.push(localOnlyBlob);
752765
}
@@ -808,7 +821,12 @@ export const useRecording = (
808821
currentRecordingStreamRef.current = null;
809822
localStreamRef.current = null;
810823
if (displayMessageRef.current) {
811-
displayMessageRef.current('Failed to stop local recording', 'error');
824+
displayMessageRef.current(
825+
error instanceof TimeoutError
826+
? 'Stopping local recording took too long and was aborted'
827+
: 'Failed to stop local recording',
828+
'error',
829+
);
812830
}
813831
return null;
814832
}

src/hooks/useVirtualBackground.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { useState, useCallback, useEffect, useRef, MutableRefObject } from 'reac
44
import { getVirtualBackgroundConfigs } from '../utils/conferenceConfig';
55
import { VirtualBackgroundTypes } from 'red5pro-conference-sdk';
66
import log from 'loglevel';
7+
import { withTimeout } from '../utils/withTimeout';
8+
9+
// Some browsers (notably Safari, which falls back to an OffscreenCanvas/WASM
10+
// segmentation pipeline instead of insertable streams) can hang indefinitely
11+
// when initializing or applying a virtual background. Cap the wait so the UI
12+
// never gets stuck reporting the effect as enabled when it isn't working.
13+
const VIRTUAL_BACKGROUND_TIMEOUT_MS = 15000;
714

815
// Type definitions
916
type VirtualBackgroundType = 'none' | 'blur' | 'slight-blur' | 'color' | 'image';
@@ -152,7 +159,11 @@ export const useVirtualBackground = (
152159
const status = conferenceClientRef.current.getVirtualBackgroundStatus();
153160

154161
if (!status.isInitialized) {
155-
await conferenceClientRef.current.initializeVirtualBackground();
162+
await withTimeout(
163+
conferenceClientRef.current.initializeVirtualBackground(),
164+
VIRTUAL_BACKGROUND_TIMEOUT_MS,
165+
'Timed out initializing virtual background',
166+
);
156167
log.log('Virtual background initialized');
157168
setIsVirtualBackgroundInitialized(true);
158169
}
@@ -204,7 +215,11 @@ export const useVirtualBackground = (
204215

205216
// Handle disable case
206217
if (type === VirtualBackgroundTypes.NONE) {
207-
await conferenceClientRef.current.disableVirtualBackground();
218+
await withTimeout(
219+
conferenceClientRef.current.disableVirtualBackground(),
220+
VIRTUAL_BACKGROUND_TIMEOUT_MS,
221+
'Timed out disabling virtual background',
222+
);
208223
setSelectedBackgroundMode('');
209224
return true;
210225
}
@@ -233,14 +248,22 @@ export const useVirtualBackground = (
233248
}
234249

235250
// Apply the background
236-
await conferenceClientRef.current[action](config.type, config.options);
251+
await withTimeout(
252+
conferenceClientRef.current[action](config.type, config.options),
253+
VIRTUAL_BACKGROUND_TIMEOUT_MS,
254+
`Timed out applying virtual background (${action})`,
255+
);
237256
setSelectedBackgroundMode(type);
238257

239258
log.log(`Virtual background ${action} successful: ${type}`);
240259
return true;
241260
} catch (error) {
242261
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
243262
log.error('Failed to handle background replacement:', error);
263+
// Ensure the UI never reports the effect as enabled when the apply call
264+
// hung/failed and the SDK never actually produced the processed frame.
265+
setIsVirtualBackgroundEnabled(false);
266+
setSelectedBackgroundMode('');
244267
if (showWarningRef.current) {
245268
showWarningRef.current('Failed to apply virtual background: ' + errorMessage);
246269
}

src/pages/LeftTheRoom/LeftTheRoom.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,13 @@ function LeftTheRoom({
138138
)}
139139

140140
{hasRecording && (
141-
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, margin: '21px' }}>
141+
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 2 }}>
142142
<Button
143143
size="small"
144144
color="primary"
145145
variant="outlined"
146146
onClick={() => downloadLocalRecording?.()}
147-
sx={{ whiteSpace: 'nowrap', width: '100%' }}
147+
sx={{ width: '100%' }}
148148
>
149149
{t('Download Recording')}
150150
</Button>
@@ -155,7 +155,7 @@ function LeftTheRoom({
155155
variant="contained"
156156
startIcon={<ReplayIcon />}
157157
onClick={() => retryUploadLocalRecording?.()}
158-
sx={{ whiteSpace: 'nowrap', width: '100%' }}
158+
sx={{ width: '100%' }}
159159
>
160160
{t('Retry Upload')}
161161
</Button>

src/pages/Meeting/MeetingPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const MeetingPage = React.memo<MeetingPageProps>((props) => {
143143
props.isMyCamTurnedOff,
144144
props.isRaiseHand,
145145
props.currentConferenceClient,
146+
props.selectedBackgroundMode,
146147
]);
147148

148149
const keyboardShortcuts = useKeyboardShortcuts({

src/utils/MediabunnyRecorder.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,6 @@ export class MediabunnyRecorder {
149149
this.recordedBlob = new Blob([this.target.buffer], { type: 'video/mp4' });
150150
}
151151

152-
this._isRecording = false;
153-
this._isPaused = false;
154-
155152
if (this.onstop && this.recordedBlob) {
156153
this.onstop(this.recordedBlob);
157154
}
@@ -163,6 +160,10 @@ export class MediabunnyRecorder {
163160
}
164161
throw error;
165162
} finally {
163+
// Reset regardless of outcome so callers never see a stuck "recording" state
164+
// if finalize() throws or is abandoned via a timeout.
165+
this._isRecording = false;
166+
this._isPaused = false;
166167
this.cleanup();
167168
}
168169
}

src/utils/withTimeout.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Races a promise against a timeout so a hung Safari media API call
3+
* (WebCodecs encoder flush, WASM/OffscreenCanvas segmentation, etc.)
4+
* can't leave callers awaiting forever.
5+
*/
6+
export class TimeoutError extends Error {
7+
constructor(message: string) {
8+
super(message);
9+
this.name = 'TimeoutError';
10+
}
11+
}
12+
13+
export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
14+
return new Promise<T>((resolve, reject) => {
15+
const timer = setTimeout(() => reject(new TimeoutError(message)), ms);
16+
promise.then(
17+
(value) => {
18+
clearTimeout(timer);
19+
resolve(value);
20+
},
21+
(error) => {
22+
clearTimeout(timer);
23+
reject(error);
24+
},
25+
);
26+
});
27+
}

0 commit comments

Comments
 (0)