Skip to content

Commit e04011e

Browse files
committed
feat: add microphone toggle functionality and related tests
- Implemented microphone toggle button in SessionViewerPage with visual feedback for mic status. - Enhanced useWebRTC hook to manage microphone state, including capturing audio and toggling mic on/off. - Added mute message handling in signaling for host to control viewer's microphone. - Created tests for microphone toggle functionality in SessionViewerPage. - Updated VideoViewer component to default to unmuted state. - Added unit tests for useWebRTC and useWebRTCHost hooks to cover microphone functionality and viewer audio management.
1 parent 1554adb commit e04011e

19 files changed

Lines changed: 1598 additions & 118 deletions

File tree

‎apps/desktop/src/main/window.ts‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,27 +57,30 @@ export async function createMainWindow(isWayland: boolean): Promise<BrowserWindo
5757
`[Main] Window: isWayland=${String(isWayland)}, XDG_SESSION_TYPE=${process.env.XDG_SESSION_TYPE ?? 'unset'}, WAYLAND_DISPLAY=${process.env.WAYLAND_DISPLAY ?? 'unset'}`
5858
);
5959

60-
session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => {
61-
console.log('[Main] Display media request received');
60+
session.defaultSession.setDisplayMediaRequestHandler(
61+
(_request, callback) => {
62+
console.log('[Main] Display media request received');
6263

63-
desktopCapturer
64-
.getSources({
65-
types: ['screen', 'window'],
66-
})
67-
.then((sources) => {
68-
if (sources.length > 0) {
69-
console.log('[Main] Granting access to source:', sources[0].name);
70-
callback({ video: sources[0] });
71-
} else {
72-
console.log('[Main] No sources available');
64+
desktopCapturer
65+
.getSources({
66+
types: ['screen', 'window'],
67+
})
68+
.then((sources) => {
69+
if (sources.length > 0) {
70+
console.log('[Main] Granting access to source:', sources[0].name);
71+
callback({ video: sources[0] });
72+
} else {
73+
console.log('[Main] No sources available');
74+
callback({});
75+
}
76+
})
77+
.catch((err: unknown) => {
78+
console.error('[Main] Failed to get sources:', err);
7379
callback({});
74-
}
75-
})
76-
.catch((err: unknown) => {
77-
console.error('[Main] Failed to get sources:', err);
78-
callback({});
79-
});
80-
});
80+
});
81+
},
82+
{ useSystemPicker: true }
83+
);
8184

8285
// Handle permission requests
8386
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {

‎apps/desktop/src/renderer/components/ParticipantList.test.tsx‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,4 +354,183 @@ describe('ParticipantList', () => {
354354
});
355355
});
356356
});
357+
358+
describe('Mute Actions', () => {
359+
it('shows mute button when onMuteParticipant is provided and isHost', () => {
360+
const participants = [
361+
createMockParticipant({
362+
id: 'p-1',
363+
user_id: 'user-1',
364+
display_name: 'Viewer',
365+
role: 'viewer',
366+
}),
367+
];
368+
369+
render(
370+
<ParticipantList
371+
participants={participants}
372+
isHost={true}
373+
onGrantControl={vi.fn()}
374+
onRevokeControl={vi.fn()}
375+
onKickParticipant={vi.fn()}
376+
onMuteParticipant={vi.fn()}
377+
mutedParticipants={new Set<string>()}
378+
/>
379+
);
380+
381+
expect(screen.getByTitle('Mute participant')).toBeInTheDocument();
382+
});
383+
384+
it('does not show mute button when onMuteParticipant is not provided', () => {
385+
const participants = [
386+
createMockParticipant({
387+
id: 'p-1',
388+
user_id: 'user-1',
389+
display_name: 'Viewer',
390+
role: 'viewer',
391+
}),
392+
];
393+
394+
render(
395+
<ParticipantList
396+
participants={participants}
397+
isHost={true}
398+
onGrantControl={vi.fn()}
399+
onRevokeControl={vi.fn()}
400+
onKickParticipant={vi.fn()}
401+
/>
402+
);
403+
404+
expect(screen.queryByTitle('Mute participant')).not.toBeInTheDocument();
405+
});
406+
407+
it('shows unmute button for muted participants', () => {
408+
const participants = [
409+
createMockParticipant({
410+
id: 'p-1',
411+
user_id: 'user-1',
412+
display_name: 'Viewer',
413+
role: 'viewer',
414+
}),
415+
];
416+
417+
render(
418+
<ParticipantList
419+
participants={participants}
420+
isHost={true}
421+
onGrantControl={vi.fn()}
422+
onRevokeControl={vi.fn()}
423+
onKickParticipant={vi.fn()}
424+
onMuteParticipant={vi.fn()}
425+
mutedParticipants={new Set<string>(['user-1'])}
426+
/>
427+
);
428+
429+
expect(screen.getByTitle('Unmute participant')).toBeInTheDocument();
430+
});
431+
432+
it('calls onMuteParticipant with user_id and true when muting', () => {
433+
const onMuteParticipant = vi.fn();
434+
const participants = [
435+
createMockParticipant({
436+
id: 'p-1',
437+
user_id: 'user-1',
438+
display_name: 'Viewer',
439+
role: 'viewer',
440+
}),
441+
];
442+
443+
render(
444+
<ParticipantList
445+
participants={participants}
446+
isHost={true}
447+
onGrantControl={vi.fn()}
448+
onRevokeControl={vi.fn()}
449+
onKickParticipant={vi.fn()}
450+
onMuteParticipant={onMuteParticipant}
451+
mutedParticipants={new Set<string>()}
452+
/>
453+
);
454+
455+
fireEvent.click(screen.getByTitle('Mute participant'));
456+
expect(onMuteParticipant).toHaveBeenCalledWith('user-1', true);
457+
});
458+
459+
it('calls onMuteParticipant with user_id and false when unmuting', () => {
460+
const onMuteParticipant = vi.fn();
461+
const participants = [
462+
createMockParticipant({
463+
id: 'p-1',
464+
user_id: 'user-1',
465+
display_name: 'Viewer',
466+
role: 'viewer',
467+
}),
468+
];
469+
470+
render(
471+
<ParticipantList
472+
participants={participants}
473+
isHost={true}
474+
onGrantControl={vi.fn()}
475+
onRevokeControl={vi.fn()}
476+
onKickParticipant={vi.fn()}
477+
onMuteParticipant={onMuteParticipant}
478+
mutedParticipants={new Set<string>(['user-1'])}
479+
/>
480+
);
481+
482+
fireEvent.click(screen.getByTitle('Unmute participant'));
483+
expect(onMuteParticipant).toHaveBeenCalledWith('user-1', false);
484+
});
485+
486+
it('does not show mute button for host participant', () => {
487+
const participants = [
488+
createMockParticipant({
489+
id: 'p-1',
490+
user_id: 'user-1',
491+
display_name: 'Host',
492+
role: 'host',
493+
}),
494+
];
495+
496+
render(
497+
<ParticipantList
498+
participants={participants}
499+
isHost={true}
500+
onGrantControl={vi.fn()}
501+
onRevokeControl={vi.fn()}
502+
onKickParticipant={vi.fn()}
503+
onMuteParticipant={vi.fn()}
504+
mutedParticipants={new Set<string>()}
505+
/>
506+
);
507+
508+
expect(screen.queryByTitle('Mute participant')).not.toBeInTheDocument();
509+
});
510+
511+
it('does not show mute button for participant with null user_id', () => {
512+
const participants = [
513+
createMockParticipant({
514+
id: 'p-1',
515+
user_id: null,
516+
display_name: 'Anonymous',
517+
role: 'viewer',
518+
}),
519+
];
520+
521+
render(
522+
<ParticipantList
523+
participants={participants}
524+
isHost={true}
525+
onGrantControl={vi.fn()}
526+
onRevokeControl={vi.fn()}
527+
onKickParticipant={vi.fn()}
528+
onMuteParticipant={vi.fn()}
529+
mutedParticipants={new Set<string>()}
530+
/>
531+
);
532+
533+
expect(screen.queryByTitle('Mute participant')).not.toBeInTheDocument();
534+
});
535+
});
357536
});

‎apps/desktop/src/renderer/components/ParticipantList.tsx‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
import { Users, Crown, Eye, Monitor, Circle, Shield, UserX, Loader2 } from 'lucide-react';
1+
import {
2+
Users,
3+
Crown,
4+
Eye,
5+
Monitor,
6+
Circle,
7+
Shield,
8+
UserX,
9+
Loader2,
10+
Mic,
11+
MicOff,
12+
} from 'lucide-react';
213
import { useState, useCallback } from 'react';
314
import type { SessionParticipant } from '@pairux/shared-types';
415

@@ -10,6 +21,8 @@ interface ParticipantListProps {
1021
onGrantControl?: (participantId: string) => Promise<void>;
1122
onRevokeControl?: (participantId: string) => Promise<void>;
1223
onKickParticipant?: (participantId: string) => Promise<void>;
24+
onMuteParticipant?: (participantId: string, muted: boolean) => void;
25+
mutedParticipants?: Set<string>;
1326
}
1427

1528
function getConnectionColor(status: SessionParticipant['connection_status']): string {
@@ -68,6 +81,8 @@ export function ParticipantList({
6881
onGrantControl,
6982
onRevokeControl,
7083
onKickParticipant,
84+
onMuteParticipant,
85+
mutedParticipants,
7186
}: ParticipantListProps) {
7287
const [loadingAction, setLoadingAction] = useState<string | null>(null);
7388
const activeParticipants = participants.filter((p) => !p.left_at);
@@ -171,6 +186,35 @@ export function ParticipantList({
171186
{/* Host actions */}
172187
{showActions && (
173188
<div className="flex gap-1">
189+
{/* Mute/Unmute button */}
190+
{onMuteParticipant &&
191+
participant.user_id &&
192+
(() => {
193+
const userId = participant.user_id;
194+
const isMutedNow = mutedParticipants?.has(userId) ?? false;
195+
return (
196+
<button
197+
onClick={() => {
198+
onMuteParticipant(userId, !isMutedNow);
199+
}}
200+
disabled={loadingAction !== null}
201+
className={`rounded p-1.5 transition-colors disabled:opacity-50 ${
202+
isMutedNow
203+
? 'text-destructive hover:bg-destructive/20'
204+
: 'text-muted-foreground hover:bg-muted'
205+
}`}
206+
title={isMutedNow ? 'Unmute participant' : 'Mute participant'}
207+
aria-label={isMutedNow ? 'Unmute participant' : 'Mute participant'}
208+
>
209+
{isMutedNow ? (
210+
<MicOff className="h-4 w-4" />
211+
) : (
212+
<Mic className="h-4 w-4" />
213+
)}
214+
</button>
215+
);
216+
})()}
217+
174218
{/* Grant/Revoke control button */}
175219
{participant.control_state === 'granted' ? (
176220
<button

0 commit comments

Comments
 (0)