Skip to content

Commit 9b4feee

Browse files
committed
fix(audio): restore host/viewer playback and web chat
1 parent 73e4446 commit 9b4feee

12 files changed

Lines changed: 103 additions & 10 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.39",
3+
"version": "0.5.40",
44
"private": true,
55
"description": "PairUX Desktop - Screen sharing with remote control",
66
"author": "PairUX Team <hello@pairux.com>",

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,6 @@ export function VideoViewer({
171171
.play()
172172
.then(() => {
173173
setRequiresUnmute(false);
174-
setMutedState(false);
175174
console.log('[VideoViewer] Audio unmuted after user gesture');
176175
})
177176
.catch((err: unknown) => {

apps/desktop/src/renderer/hooks/useWebRTCHostAPI.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ class MockRTCPeerConnection {
5252
remoteDescription: RTCSessionDescriptionInit | null = null;
5353
onicecandidate: ((event: { candidate: unknown }) => void) | null = null;
5454
onconnectionstatechange: (() => void) | null = null;
55+
ontrack: ((event: { track: MediaStreamTrack }) => void) | null = null;
5556

5657
createOffer = vi.fn().mockResolvedValue({ type: 'offer', sdp: 'test-sdp' });
5758
createAnswer = vi.fn().mockResolvedValue({ type: 'answer', sdp: 'test-answer-sdp' });
@@ -113,7 +114,20 @@ describe('useWebRTCHostAPI', () => {
113114
(globalThis as Record<string, unknown>).EventSource = MockEventSource;
114115
(globalThis as Record<string, unknown>).RTCPeerConnection = MockRTCPeerConnection;
115116
(globalThis as Record<string, unknown>).RTCIceCandidate = vi.fn((c: unknown) => c);
117+
(globalThis as Record<string, unknown>).MediaStream = vi.fn((tracks: unknown[]) => ({
118+
getTracks: () => tracks,
119+
getAudioTracks: () => tracks.filter((track) => (track as { kind?: string }).kind === 'audio'),
120+
getVideoTracks: () => tracks.filter((track) => (track as { kind?: string }).kind === 'video'),
121+
}));
116122
(globalThis as Record<string, unknown>).fetch = mockFetch;
123+
(globalThis as Record<string, unknown>).Audio = vi.fn(() => ({
124+
srcObject: null,
125+
autoplay: false,
126+
volume: 1,
127+
muted: false,
128+
play: vi.fn().mockResolvedValue(undefined),
129+
pause: vi.fn(),
130+
}));
117131
});
118132

119133
afterEach(() => {
@@ -383,6 +397,47 @@ describe('useWebRTCHostAPI', () => {
383397
const mockPC = viewer?.peerConnection as unknown as MockRTCPeerConnection;
384398
expect(mockPC.createDataChannel).toHaveBeenCalledWith('control', { ordered: true });
385399
});
400+
401+
it('should create a local audio element when a viewer audio track is received', async () => {
402+
const { result } = renderHook(() => useWebRTCHostAPI(defaultOptions));
403+
404+
await act(async () => {
405+
await result.current.startHosting();
406+
});
407+
408+
const es = MockEventSource.instances[0];
409+
act(() => {
410+
es.emit('connected', JSON.stringify({ sessionId: 'session-1' }));
411+
});
412+
413+
await act(async () => {
414+
es.emit(
415+
'presence-join',
416+
JSON.stringify({
417+
presences: [{ user_id: 'viewer-1', role: 'viewer' }],
418+
})
419+
);
420+
});
421+
422+
await act(async () => {
423+
await vi.waitFor(() => {
424+
expect(result.current.viewerCount).toBe(1);
425+
});
426+
});
427+
428+
const viewer = result.current.viewers.get('viewer-1');
429+
const mockPC = viewer?.peerConnection as unknown as MockRTCPeerConnection;
430+
const audioTrack = { kind: 'audio' } as MediaStreamTrack;
431+
432+
await act(async () => {
433+
mockPC.ontrack?.({ track: audioTrack });
434+
});
435+
436+
const updatedViewer = result.current.viewers.get('viewer-1');
437+
expect(updatedViewer?.audioTrack).toBe(audioTrack);
438+
expect(updatedViewer?.audioElement).toBeTruthy();
439+
expect((globalThis as Record<string, unknown>).Audio).toHaveBeenCalled();
440+
});
386441
});
387442

388443
describe('signal handling', () => {

apps/desktop/src/renderer/hooks/useWebRTCHostAPI.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,17 @@ export function useWebRTCHostAPI({
454454
if (viewer) {
455455
viewer.audioTrack = event.track;
456456

457+
// Play viewer audio locally so the host can hear participants
458+
const audioEl = new Audio();
459+
audioEl.srcObject = new MediaStream([event.track]);
460+
audioEl.autoplay = true;
461+
audioEl.volume = 1.0;
462+
audioEl.muted = viewer.isMuted;
463+
void audioEl.play().catch((err: unknown) => {
464+
console.warn('[WebRTCHost] Failed to play viewer audio:', err);
465+
});
466+
viewer.audioElement = audioEl;
467+
457468
setViewers(new Map(viewersRef.current));
458469

459470
// Relay this viewer's audio to all other viewers

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.39",
3+
"version": "0.5.40",
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.39",
3+
"version": "0.5.40",
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.39",
3+
"version": "0.5.40",
44
"private": true,
55
"type": "module",
66
"scripts": {

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ interface SessionViewerContentProps {
246246
function SessionViewerContent({
247247
sessionId,
248248
session,
249-
participantId,
249+
participantId: _participantId,
250250
remoteCursors,
251251
connectionState,
252252
remoteStream,
@@ -478,7 +478,6 @@ function SessionViewerContent({
478478
{activePanel === 'chat' && (
479479
<ChatPanel
480480
sessionId={sessionId}
481-
participantId={participantId}
482481
isCollapsed={false}
483482
onToggleCollapse={() => {
484483
setActivePanel(null);

apps/web/src/components/video/VideoViewer.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ describe('VideoViewer', () => {
9898
expect(screen.getByTitle('Turn speaker on (M)')).toBeInTheDocument();
9999
});
100100

101+
it('honors controlled speakerMuted state and calls onSpeakerMutedChange', () => {
102+
const mockStream = createMockStream(['audio', 'video']);
103+
const onSpeakerMutedChange = vi.fn();
104+
105+
const { rerender } = render(
106+
<VideoViewer
107+
{...defaultProps}
108+
stream={mockStream}
109+
connectionState="connected"
110+
speakerMuted={false}
111+
onSpeakerMutedChange={onSpeakerMutedChange}
112+
/>
113+
);
114+
115+
fireEvent.click(screen.getByTitle('Mute (M)'));
116+
expect(onSpeakerMutedChange).toHaveBeenCalledWith(true);
117+
118+
rerender(
119+
<VideoViewer
120+
{...defaultProps}
121+
stream={mockStream}
122+
connectionState="connected"
123+
speakerMuted={true}
124+
onSpeakerMutedChange={onSpeakerMutedChange}
125+
/>
126+
);
127+
128+
expect(screen.getByTitle('Unmute (M)')).toBeInTheDocument();
129+
});
130+
101131
it('applies custom className', () => {
102132
const { container } = render(<VideoViewer {...defaultProps} className="custom-class" />);
103133
expect(container.firstChild).toHaveClass('custom-class');

apps/web/src/components/video/VideoViewer.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,6 @@ export function VideoViewer({
6666
.play()
6767
.then(() => {
6868
setNeedsAudioGesture(false);
69-
setMutedState(false);
7069
})
7170
.catch(() => {
7271
// Unmuted play blocked (iOS) — retry muted

0 commit comments

Comments
 (0)