Skip to content

Commit c9867ed

Browse files
committed
Add speaker controls parity across desktop and web
1 parent b06d222 commit c9867ed

13 files changed

Lines changed: 185 additions & 27 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pairux/desktop",
3-
"version": "0.5.37",
3+
"version": "0.5.38",
44
"private": true,
55
"description": "PairUX Desktop - Screen sharing with remote control",
66
"author": "PairUX Team <hello@pairux.com>",

apps/desktop/src/renderer/components/capture/CapturePreview.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function CapturePreview({
9898
});
9999
const [includeAudio, setIncludeAudio] = useState(true);
100100
const [mutedParticipants, setMutedParticipants] = useState<Set<string>>(new Set());
101+
const [speakerMuted, setSpeakerMuted] = useState(false);
101102
const [spaceWarning, setSpaceWarning] = useState<number | null>(null);
102103
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
103104

@@ -260,6 +261,14 @@ export function CapturePreview({
260261
};
261262
}, [disposeMixer]);
262263

264+
useEffect(() => {
265+
for (const viewer of hostedViewers.values()) {
266+
if (viewer.audioElement) {
267+
viewer.audioElement.muted = speakerMuted || viewer.isMuted;
268+
}
269+
}
270+
}, [hostedViewers, speakerMuted]);
271+
263272
// Start hosting (voice channel) when session is available -- no stream required
264273
useEffect(() => {
265274
if (session !== null && !isHosting) {
@@ -883,6 +892,17 @@ export function CapturePreview({
883892
</div>
884893

885894
{/* Microphone toggle for streaming audio */}
895+
<Button
896+
variant={speakerMuted ? 'secondary' : 'default'}
897+
size="sm"
898+
onClick={() => {
899+
setSpeakerMuted((prev) => !prev);
900+
}}
901+
title={speakerMuted ? 'Turn speaker on' : 'Turn speaker off'}
902+
>
903+
{speakerMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
904+
{speakerMuted ? 'Speaker Off' : 'Speaker On'}
905+
</Button>
886906
<Button
887907
variant={hostMicEnabled ? 'default' : 'secondary'}
888908
size="sm"

apps/desktop/src/renderer/components/video/VideoViewer.tsx

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ interface VideoViewerProps {
1919
networkQuality?: NetworkQuality;
2020
error?: string | null;
2121
onReconnect?: () => void;
22+
speakerMuted?: boolean;
23+
onSpeakerMutedChange?: (muted: boolean) => void;
24+
showSpeakerToggle?: boolean;
2225
className?: string;
2326
}
2427

@@ -27,11 +30,25 @@ export function VideoViewer({
2730
connectionState,
2831
error,
2932
onReconnect,
33+
speakerMuted,
34+
onSpeakerMutedChange,
35+
showSpeakerToggle = true,
3036
className = '',
3137
}: VideoViewerProps) {
3238
const videoRef = useRef<HTMLVideoElement>(null);
33-
const [isMuted, setIsMuted] = useState(false);
39+
const [localMuted, setLocalMuted] = useState(false);
3440
const [requiresUnmute, setRequiresUnmute] = useState(false);
41+
const isMuted = speakerMuted ?? localMuted;
42+
43+
const setMutedState = useCallback(
44+
(muted: boolean) => {
45+
onSpeakerMutedChange?.(muted);
46+
if (speakerMuted === undefined) {
47+
setLocalMuted(muted);
48+
}
49+
},
50+
[onSpeakerMutedChange, speakerMuted]
51+
);
3552

3653
useEffect(() => {
3754
const video = videoRef.current;
@@ -89,7 +106,7 @@ export function VideoViewer({
89106
// Unmuted play blocked — retry muted so the video at least renders
90107
console.warn('[VideoViewer] Unmuted autoplay blocked, retrying muted');
91108
video.muted = true;
92-
setIsMuted(true);
109+
setMutedState(true);
93110
video.play().catch((err: unknown) => {
94111
console.error('[VideoViewer] Failed to play even muted:', err);
95112
});
@@ -109,18 +126,18 @@ export function VideoViewer({
109126
video.removeEventListener('waiting', handleWaiting);
110127
video.removeEventListener('error', handleError);
111128
};
112-
}, [stream, isMuted]);
129+
}, [stream, isMuted, setMutedState]);
113130

114131
const toggleMute = useCallback(() => {
115132
const video = videoRef.current;
116133
if (!video) return;
117134

118135
video.muted = !video.muted;
119-
setIsMuted(video.muted);
136+
setMutedState(video.muted);
120137
if (!video.muted) {
121138
setRequiresUnmute(false);
122139
}
123-
}, []);
140+
}, [setMutedState]);
124141

125142
const hasVideoTrack = Boolean(stream && stream.getVideoTracks().length > 0);
126143
const hasAudioTrack = Boolean(stream && stream.getAudioTracks().length > 0);
@@ -154,6 +171,7 @@ export function VideoViewer({
154171
.play()
155172
.then(() => {
156173
setRequiresUnmute(false);
174+
setMutedState(false);
157175
console.log('[VideoViewer] Audio unmuted after user gesture');
158176
})
159177
.catch((err: unknown) => {
@@ -239,7 +257,7 @@ export function VideoViewer({
239257
</div>
240258

241259
{/* Speaker toggle */}
242-
{(isStreaming || isVoiceOnly) && hasAudioTrack && (
260+
{showSpeakerToggle && (isStreaming || isVoiceOnly) && hasAudioTrack && (
243261
<button
244262
type="button"
245263
onClick={toggleMute}

apps/desktop/src/renderer/routes/viewer.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { useEffect, useState, useCallback } from 'react';
22
import { useParams, useNavigate } from 'react-router-dom';
3-
import { Users, Loader2, LogOut, MessageSquare, AlertCircle, Mic, MicOff } from 'lucide-react';
3+
import {
4+
Users,
5+
Loader2,
6+
LogOut,
7+
MessageSquare,
8+
AlertCircle,
9+
Mic,
10+
MicOff,
11+
Volume2,
12+
VolumeX,
13+
} from 'lucide-react';
414
import { ChatPanel } from '@/components/chat';
515
import { VideoViewer } from '@/components/video/VideoViewer';
616
import { useAuthStore } from '@/stores/auth';
@@ -216,6 +226,7 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont
216226
const navigate = useNavigate();
217227
const [showChat, setShowChat] = useState(true);
218228
const [leaving, setLeaving] = useState(false);
229+
const [speakerMuted, setSpeakerMuted] = useState(false);
219230

220231
const {
221232
connectionState,
@@ -253,6 +264,21 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont
253264
</div>
254265

255266
<div className="flex items-center gap-2">
267+
<button
268+
onClick={() => {
269+
setSpeakerMuted((prev) => !prev);
270+
}}
271+
className={`flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
272+
speakerMuted
273+
? 'bg-muted text-muted-foreground hover:bg-muted/80'
274+
: 'bg-blue-700 text-white hover:bg-blue-600'
275+
}`}
276+
title={speakerMuted ? 'Turn speaker on' : 'Turn speaker off'}
277+
>
278+
{speakerMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
279+
{speakerMuted ? 'Speaker Off' : 'Speaker On'}
280+
</button>
281+
256282
{/* Mic toggle */}
257283
<button
258284
onClick={toggleMic}
@@ -341,6 +367,9 @@ function ViewerContent({ session, participants, userId, hookResult }: ViewerCont
341367
connectionState={connectionState}
342368
error={webrtcError}
343369
onReconnect={reconnect}
370+
speakerMuted={speakerMuted}
371+
onSpeakerMutedChange={setSpeakerMuted}
372+
showSpeakerToggle={false}
344373
className="flex-1"
345374
/>
346375
</div>

apps/installer/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pairux/installer",
3-
"version": "0.5.37",
3+
"version": "0.5.38",
44
"private": true,
55
"description": "PairUX Desktop App Installer Service",
66
"type": "module",

apps/turn/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pairux/turn",
3-
"version": "0.5.37",
3+
"version": "0.5.38",
44
"private": true,
55
"description": "PairUX TURN/STUN Server (coturn)",
66
"scripts": {

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pairux/web",
3-
"version": "0.5.37",
3+
"version": "0.5.38",
44
"private": true,
55
"type": "module",
66
"scripts": {

apps/web/src/app/host/[id]/page.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
Download,
2222
Mic,
2323
MicOff,
24+
Volume2,
25+
VolumeX,
2426
} from 'lucide-react';
2527
import { VideoPreview } from '@/components/video';
2628
import { HostParticipantList } from '@/components/participants/HostParticipantList';
@@ -186,6 +188,7 @@ function HostContent({
186188
const [captureQuality, setCaptureQuality] = useState<CaptureQuality>('1080p');
187189
const [recordingQuality, setRecordingQuality] = useState<RecordingQuality>('1080p');
188190
const [recordingBlob, setRecordingBlob] = useState<Blob | null>(null);
191+
const [speakerMuted, setSpeakerMuted] = useState(false);
189192

190193
// Screen capture hook
191194
const {
@@ -299,6 +302,14 @@ function HostContent({
299302
[kickViewer, resolveViewerTargetId]
300303
);
301304

305+
useEffect(() => {
306+
for (const viewer of hostedViewers.values()) {
307+
if (viewer.audioElement) {
308+
viewer.audioElement.muted = speakerMuted || viewer.isMuted;
309+
}
310+
}
311+
}, [hostedViewers, speakerMuted]);
312+
302313
// Audio mixer: combines host mic + all viewer audio into one stream for recording
303314
const {
304315
mixedStream,
@@ -588,6 +599,24 @@ function HostContent({
588599
)}
589600

590601
{/* Mic toggle */}
602+
<button
603+
type="button"
604+
onClick={() => {
605+
setSpeakerMuted((prev) => !prev);
606+
}}
607+
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
608+
speakerMuted
609+
? 'border border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700'
610+
: 'bg-blue-700 text-white hover:bg-blue-600'
611+
}`}
612+
title={speakerMuted ? 'Turn speaker on' : 'Turn speaker off'}
613+
>
614+
{speakerMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
615+
<span className="hidden sm:inline">
616+
{speakerMuted ? 'Speaker Off' : 'Speaker On'}
617+
</span>
618+
</button>
619+
591620
<button
592621
type="button"
593622
onClick={toggleMic}

apps/web/src/app/session/[id]/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
AlertCircle,
1212
Mic,
1313
MicOff,
14+
Volume2,
15+
VolumeX,
1416
} from 'lucide-react';
1517
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
1618
import type {
@@ -263,6 +265,7 @@ function SessionViewerContent({
263265
toggleMic,
264266
}: SessionViewerContentProps) {
265267
const [activePanel, setActivePanel] = useState<SidebarPanel>('participants');
268+
const [speakerMuted, setSpeakerMuted] = useState(false);
266269
const videoContainerRef = useRef<HTMLDivElement>(null);
267270

268271
// Track host presence in real-time
@@ -340,6 +343,9 @@ function SessionViewerContent({
340343
networkQuality={networkQuality}
341344
error={webrtcError}
342345
onReconnect={reconnect}
346+
speakerMuted={speakerMuted}
347+
onSpeakerMutedChange={setSpeakerMuted}
348+
showSpeakerToggle={false}
343349
className="h-full"
344350
/>
345351
</InputCapture>
@@ -357,6 +363,21 @@ function SessionViewerContent({
357363
/>
358364
)}
359365
{/* Mic toggle */}
366+
<button
367+
type="button"
368+
onClick={() => {
369+
setSpeakerMuted((prev) => !prev);
370+
}}
371+
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
372+
speakerMuted
373+
? 'bg-gray-800 text-gray-300 hover:bg-gray-700'
374+
: 'bg-blue-700 text-white hover:bg-blue-600'
375+
}`}
376+
title={speakerMuted ? 'Turn speaker on' : 'Turn speaker off'}
377+
>
378+
{speakerMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
379+
{speakerMuted ? 'Speaker Off' : 'Speaker On'}
380+
</button>
360381
<button
361382
type="button"
362383
onClick={toggleMic}

apps/web/src/app/view/[sessionId]/page.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
AlertCircle,
1313
Mic,
1414
MicOff,
15+
Volume2,
16+
VolumeX,
1517
} from 'lucide-react';
1618
import { Wifi, WifiOff, RefreshCw } from 'lucide-react';
1719
import { HostPresenceIndicator } from '@/components/session/HostPresenceIndicator';
@@ -290,6 +292,7 @@ function GuestViewerContent({
290292
toggleMic,
291293
}: GuestViewerContentProps) {
292294
const videoContainerRef = useRef<HTMLDivElement>(null);
295+
const [speakerMuted, setSpeakerMuted] = useState(false);
293296
const allowControl = session.settings.allowControl ?? false;
294297
const activeParticipants = session.session_participants.filter((p) => p.role !== 'left');
295298

@@ -364,6 +367,9 @@ function GuestViewerContent({
364367
networkQuality={networkQuality}
365368
error={webrtcError}
366369
onReconnect={reconnect}
370+
speakerMuted={speakerMuted}
371+
onSpeakerMutedChange={setSpeakerMuted}
372+
showSpeakerToggle={false}
367373
className="h-full"
368374
/>
369375
</InputCapture>
@@ -380,6 +386,21 @@ function GuestViewerContent({
380386
onReleaseControl={releaseControl}
381387
/>
382388
)}
389+
<button
390+
type="button"
391+
onClick={() => {
392+
setSpeakerMuted((prev) => !prev);
393+
}}
394+
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
395+
speakerMuted
396+
? 'bg-gray-800 text-gray-300 hover:bg-gray-700'
397+
: 'bg-blue-700 text-white hover:bg-blue-600'
398+
}`}
399+
title={speakerMuted ? 'Turn speaker on' : 'Turn speaker off'}
400+
>
401+
{speakerMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
402+
{speakerMuted ? 'Speaker Off' : 'Speaker On'}
403+
</button>
383404
<button
384405
type="button"
385406
onClick={toggleMic}

0 commit comments

Comments
 (0)