Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 65 additions & 31 deletions apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ describe('useWebRTCViewerAPI', () => {
* Returns { hookResult, es, pc }.
*/
async function initWithConnected(
options: Parameters<typeof useWebRTCViewerAPI>[0] = defaultOptions
options: Parameters<typeof useWebRTCViewerAPI>[0] = defaultOptions,
connected = connectedEventData
) {
let hookResult: { current: ReturnType<typeof useWebRTCViewerAPI> };

Expand All @@ -175,7 +176,7 @@ describe('useWebRTCViewerAPI', () => {

// Emit the connected event to trigger PC creation
act(() => {
es.emit('connected', connectedEventData);
es.emit('connected', connected);
});

const pc = MockRTCPeerConnection.instances[0];
Expand Down Expand Up @@ -684,40 +685,73 @@ describe('useWebRTCViewerAPI', () => {
expect(onKicked).toHaveBeenCalled();
});

it('should handle mute message from data channel', async () => {
const { hookResult, pc } = await initWithConnected();
it.each(['viewer-1', 'server-viewer'])(
'handles mute using server subscriber identity %s',
async (subscriberId) => {
const { hookResult, pc } = await initWithConnected(
defaultOptions,
JSON.stringify({ subscriberId })
);

expect(hookResult.current.micEnabled).toBe(true);
expect(hookResult.current.micEnabled).toBe(true);

const mockChannel = {
readyState: 'open',
onopen: null as (() => void) | null,
onclose: null as (() => void) | null,
onerror: null as ((err: unknown) => void) | null,
onmessage: null as ((event: MessageEvent) => void) | null,
close: vi.fn(),
send: vi.fn(),
};
const mockChannel = {
readyState: 'open',
onopen: null as (() => void) | null,
onclose: null as (() => void) | null,
onerror: null as ((err: unknown) => void) | null,
onmessage: null as ((event: MessageEvent) => void) | null,
close: vi.fn(),
send: vi.fn(),
};

act(() => {
pc.ondatachannel?.({ channel: mockChannel });
mockChannel.onopen?.();
});
act(() => {
pc.ondatachannel?.({ channel: mockChannel });
mockChannel.onopen?.();
});

// Simulate mute message
act(() => {
mockChannel.onmessage?.({
data: JSON.stringify({
type: 'mute',
muted: true,
participantId: 'viewer-1',
timestamp: Date.now(),
}),
} as MessageEvent);
});
act(() =>
mockChannel.onmessage?.({
data: JSON.stringify({
type: 'mute',
muted: true,
participantId: 'unrelated-viewer',
}),
} as MessageEvent)
);
expect(hookResult.current.micEnabled).toBe(true);

expect(hookResult.current.micEnabled).toBe(false);
});
// Simulate mute message
act(() => {
mockChannel.onmessage?.({
data: JSON.stringify({
type: 'mute',
muted: true,
participantId: subscriberId,
timestamp: Date.now(),
}),
} as MessageEvent);
});

expect(hookResult.current.micEnabled).toBe(false);
for (const muted of [false, undefined, 'false']) {
act(() =>
mockChannel.onmessage?.({
data: JSON.stringify({ type: 'mute', muted, participantId: subscriberId }),
} as MessageEvent)
);
expect(mockAudioTrack.enabled).toBe(false);
}
expect(hookResult.current.unmuteRequested).toBe(true);
await act(async () => {
hookResult.current.reconnect();
});
expect(mockAudioTrack.enabled).toBe(false);
expect(hookResult.current.unmuteRequested).toBe(false);
act(() => hookResult.current.toggleMic());
expect(mockAudioTrack.enabled).toBe(true);
}
);

it('should send control request via data channel', async () => {
const { hookResult, pc } = await initWithConnected();
Expand Down
34 changes: 28 additions & 6 deletions apps/desktop/src/renderer/hooks/useWebRTCViewerAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ interface UseWebRTCViewerAPIReturn {
releaseControl: () => void;
sendInput: (event: InputEvent) => void;
micEnabled: boolean;
unmuteRequested: boolean;
hasMic: boolean;
toggleMic: () => void;
}
Expand All @@ -109,6 +110,8 @@ export function useWebRTCViewerAPI({
const [dataChannelReady, setDataChannelReady] = useState(false);
const [micEnabled, setMicEnabled] = useState(false);
const [hasMic, setHasMic] = useState(false);
const [unmuteRequested, setUnmuteRequested] = useState(false);
const micIntentRef = useRef(true);

const peerConnectionRef = useRef<RTCPeerConnection | null>(null);
const remoteStreamRef = useRef<MediaStream | null>(null);
Expand Down Expand Up @@ -232,13 +235,24 @@ export function useWebRTCViewerAPI({
onKickedRef.current?.(message.reason);
break;
case 'mute': {
if (
typeof message.muted !== 'boolean' ||
(message.participantId && message.participantId !== signalSenderIdRef.current)
)
break;
if (!message.muted) {
if (!micIntentRef.current) setUnmuteRequested(true);
break;
}
micIntentRef.current = false;
setUnmuteRequested(false);
const micStream = micStreamRef.current;
if (micStream) {
micStream.getAudioTracks().forEach((track) => {
track.enabled = !message.muted;
track.enabled = false;
});
setMicEnabled(!message.muted);
}
setMicEnabled(false);
break;
}
}
Expand Down Expand Up @@ -825,6 +839,7 @@ export function useWebRTCViewerAPI({

// Disconnect and clean up
const disconnect = useCallback(() => {
setUnmuteRequested(false);
lifecycleRef.current++;
hostPeerIdRef.current = null;
pendingCandidatesRef.current = [];
Expand Down Expand Up @@ -885,15 +900,17 @@ export function useWebRTCViewerAPI({
const micStream = micStreamRef.current;
if (!micStream) return;

const tracks = micStream.getAudioTracks();
const tracks = micStream.getAudioTracks().filter((track) => track.readyState !== 'ended');
if (tracks.length === 0) return;

const newEnabled = !micEnabled;
const newEnabled = !micIntentRef.current;
micIntentRef.current = newEnabled;
setUnmuteRequested(false);
tracks.forEach((track) => {
track.enabled = newEnabled;
});
setMicEnabled(newEnabled);
}, [micEnabled]);
}, []);

// Initialize connection
const initialize = useCallback(async () => {
Expand Down Expand Up @@ -931,13 +948,17 @@ export function useWebRTCViewerAPI({
return;
}
markTrackAsSpeech(micStream.getAudioTracks()[0]);
micStream.getAudioTracks().forEach((track) => {
track.enabled = micIntentRef.current;
});
micStreamRef.current = micStream;
setHasMic(true);
setMicEnabled(true);
setMicEnabled(micIntentRef.current);
console.log('[WebRTCViewer] Microphone captured');
} catch (err: unknown) {
if (!current()) return;
console.warn('[WebRTCViewer] Could not access microphone:', err);
micIntentRef.current = false;
micStreamRef.current = null;
setHasMic(false);
setMicEnabled(false);
Expand Down Expand Up @@ -1128,6 +1149,7 @@ export function useWebRTCViewerAPI({
releaseControl,
sendInput,
micEnabled,
unmuteRequested,
hasMic,
toggleMic,
};
Expand Down
Loading
Loading