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
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ private void handleSocketEvent(WebSocketSession session, String event, JsonNode
case "webrtc-offer" -> handleWebRtcSignaling("webrtc-offer", socketId, dataMap);
case "webrtc-answer" -> handleWebRtcSignaling("webrtc-answer", socketId, dataMap);
case "webrtc-ice-candidate" -> handleWebRtcSignaling("webrtc-ice-candidate", socketId, dataMap);
case "webrtc-renegotiate" -> handleWebRtcSignaling("webrtc-renegotiate", socketId, dataMap);
case "create-poll" -> handleCreatePoll(roomId, dataMap);
case "vote-poll" -> handleVotePoll(roomId, dataMap);
case "close-poll" -> handleClosePoll(roomId, dataMap);
Expand Down Expand Up @@ -205,6 +206,11 @@ private void handleJoinRoom(WebSocketSession session, Map<String, Object> dataMa

Map<String, Object> enhancedUser;
synchronized (roomLifecycleLock) {
if (roomId.equals(socketToRoom.get(socketId))) {
emitToSocket(socketId, "room-users", roomStateManager.getUsersInRoom(roomId));
return;
}

if (!roomService.prepareRoomForJoin(roomId, ownerId)) {
emitToSocket(socketId, "room-closed", Map.of("roomId", roomId));
try {
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/app/room/[roomId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export default function RoomPage() {

// Start WebRTC connection — pass state refs so recovery restores correct state
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { localStream, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack } = useWebRTC(
roomId as string, socketId, isMutedRef, isVideoOnRef
const { localStream, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack, refreshMediaConnections } = useWebRTC(
roomId as string, socketId, isMutedRef, isVideoOnRef, socket
);

const localStreamRef = useRef<MediaStream | null>(null);
Expand Down Expand Up @@ -331,13 +331,15 @@ export default function RoomPage() {
// ── Stabilized toggle handlers (no re-creation on each render) ──────
const toggleMic = useCallback(async () => {
const newMuted = !isMutedRef.current;
let acquiredTrack = false;

if (newMuted) {
localStream?.getAudioTracks().forEach(track => { track.enabled = false; });
} else {
let track = localStreamRef.current?.getAudioTracks().find(audioTrack => audioTrack.readyState === "live");
if (!track) {
track = await ensureAudioTrack() ?? undefined;
acquiredTrack = Boolean(track);
}
if (!track) {
toast({
Expand All @@ -353,7 +355,8 @@ export default function RoomPage() {
setIsMuted(newMuted);
if (socketId) updateUser(socketId, { isMuted: newMuted });
if (socket) socket.emit("toggle-mic", { roomId, userId: socketId, isMuted: newMuted });
}, [localStream, socketId, socket, roomId, updateUser, ensureAudioTrack, toast]);
if (acquiredTrack) refreshMediaConnections();
}, [localStream, socketId, socket, roomId, updateUser, ensureAudioTrack, toast, refreshMediaConnections]);

const toggleCamera = useCallback(async () => {
if (isTogglingCamera.current) return;
Expand Down Expand Up @@ -398,10 +401,11 @@ export default function RoomPage() {
setIsVideoOn(newVideoOn);
if (socketId) updateUser(socketId, { isVideoOn: newVideoOn });
if (socket) socket.emit("toggle-camera", { roomId, userId: socketId, isVideoOn: newVideoOn });
refreshMediaConnections();
} finally {
isTogglingCamera.current = false;
}
}, [socketId, socket, roomId, updateUser, ensureVideoTrack, disableVideoTrack, toast]);
}, [socketId, socket, roomId, updateUser, ensureVideoTrack, disableVideoTrack, toast, refreshMediaConnections]);

const toggleHand = useCallback(() => {
setIsHandRaised(prev => {
Expand Down
24 changes: 16 additions & 8 deletions frontend/src/components/CameraGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useEffect, useRef, useState } from "react";
* The video element is ALWAYS mounted — we toggle visibility via CSS
* instead of unmounting/remounting, which eliminates the flickering.
*/
function VideoTile({ stream, isLocal, isVideoOn }: { stream: MediaStream | undefined; isLocal?: boolean; isVideoOn?: boolean }) {
function VideoTile({ stream, isVideoOn }: { stream: MediaStream | undefined; isVideoOn?: boolean }) {
const videoRef = useRef<HTMLVideoElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -53,7 +53,7 @@ function VideoTile({ stream, isLocal, isVideoOn }: { stream: MediaStream | undef
ref={videoRef}
autoPlay
playsInline
muted={isLocal}
muted
disablePictureInPicture={true}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${isVideoOn && stream ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
/>
Expand Down Expand Up @@ -84,7 +84,8 @@ function AudioTile({ stream }: { stream: MediaStream }) {
}
};

attachAudio();
const retryAudio = () => { void attachAudio(); };
void attachAudio();

const handleVisibilityChange = () => {
if (document.visibilityState === "visible") attachAudio();
Expand All @@ -93,17 +94,24 @@ function AudioTile({ stream }: { stream: MediaStream }) {

document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleFocus);
stream.addEventListener("addtrack", attachAudio);
document.addEventListener("pointerdown", retryAudio);
document.addEventListener("keydown", retryAudio);
stream.addEventListener("addtrack", retryAudio);
const audioTracks = stream.getAudioTracks();
audioTracks.forEach((track) => track.addEventListener("unmute", retryAudio));
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
stream.removeEventListener("addtrack", attachAudio);
document.removeEventListener("pointerdown", retryAudio);
document.removeEventListener("keydown", retryAudio);
stream.removeEventListener("addtrack", retryAudio);
audioTracks.forEach((track) => track.removeEventListener("unmute", retryAudio));
};
}, [stream]);

return (
<>
<audio ref={audioRef} autoPlay />
<audio ref={audioRef} autoPlay playsInline />
{needsUserGesture && (
<button
type="button"
Expand All @@ -117,7 +125,7 @@ function AudioTile({ stream }: { stream: MediaStream }) {
console.error("Could not start remote audio:", error);
}
}}
className="absolute inset-x-1 bottom-5 z-20 rounded bg-zinc-950/90 px-1 py-0.5 text-[9px] text-white"
className="absolute inset-x-1 bottom-5 z-20 rounded bg-zinc-950/90 px-2 py-1 text-[10px] font-medium text-white"
title="Click to enable this participant's audio"
>
Enable sound
Expand Down Expand Up @@ -145,7 +153,7 @@ export default function CameraGrid({ currentUserId }: { currentUserId: string })
>
{/* Always-mounted video element — shown/hidden via opacity */}
{user.stream && (
<VideoTile stream={user.stream} isLocal={isLocal} isVideoOn={user.isVideoOn} />
<VideoTile stream={user.stream} isVideoOn={user.isVideoOn} />
)}

{/* Avatar fallback — shown when video is off */}
Expand Down
128 changes: 78 additions & 50 deletions frontend/src/hooks/useWebRTC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, useEffect, useRef, useCallback } from "react";
import Peer, { MediaConnection } from "peerjs";
import { Socket } from "socket.io-client";
import { useRoomStore } from "@/store/useRoomStore";

/**
Expand All @@ -16,8 +17,10 @@ export function useWebRTC(
userId: string,
isMutedRef?: React.MutableRefObject<boolean>,
isVideoOnRef?: React.MutableRefObject<boolean>,
socket?: Socket | null,
) {
const [peer, setPeer] = useState<Peer | null>(null);
const [peerReady, setPeerReady] = useState(false);
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [remoteStreams, setRemoteStreams] = useState<{ [id: string]: MediaStream }>({});
Expand Down Expand Up @@ -45,6 +48,69 @@ export function useWebRTC(
}
}, [localStream, userId, updateUser]);

const registerCall = useCallback((call: MediaConnection) => {
callsRef.current[call.peer] = call;
call.on("stream", (remoteStream: MediaStream) => {
if (callsRef.current[call.peer] !== call) return;
setRemoteStreams((prev) => ({ ...prev, [call.peer]: remoteStream }));
updateUser(call.peer, { stream: remoteStream });
});
call.on("error", (err) => {
console.error(`[useWebRTC] Call error with ${call.peer}:`, err);
});
call.on("close", () => {
if (callsRef.current[call.peer] !== call) return;
delete callsRef.current[call.peer];
setRemoteStreams((prev) => {
const next = { ...prev };
delete next[call.peer];
return next;
});
updateUser(call.peer, { stream: undefined });
});
}, [updateUser]);

const startCall = useCallback((targetId: string) => {
if (!peer || !peerReady || !localStreamRef.current || targetId === userIdRef.current) return;

const previousCall = callsRef.current[targetId];
try {
const call = peer.call(targetId, localStreamRef.current);
if (!call) return;
registerCall(call);
previousCall?.close();
} catch (error) {
console.error(`[useWebRTC] Could not start media call with ${targetId}:`, error);
}
}, [peer, peerReady, registerCall]);

const refreshMediaConnections = useCallback(() => {
if (!userId || !socket) return;

users.forEach((user) => {
if (user.id === userId) return;
if (userId.localeCompare(user.id) < 0) {
startCall(user.id);
} else {
socket.emit("webrtc-renegotiate", { to: user.id });
}
});
}, [socket, startCall, userId, users]);

useEffect(() => {
if (!socket) return;

const handleRenegotiate = ({ from }: { from: string }) => {
if (from && userId.localeCompare(from) < 0) {
startCall(from);
}
};
socket.on("webrtc-renegotiate", handleRenegotiate);
return () => {
socket.off("webrtc-renegotiate", handleRenegotiate);
};
}, [socket, startCall, userId]);

// ── Hot-swap helpers ────────────────────────────────────────────────

/** Replace a track in every active peer connection */
Expand Down Expand Up @@ -240,6 +306,7 @@ export function useWebRTC(
const pendingCalls: MediaConnection[] = [];
callsRef.current = {};
localStreamRef.current = null;
setPeerReady(false);
setLocalStream(null);
setRemoteStreams({});

Expand All @@ -263,34 +330,10 @@ export function useWebRTC(

setPeer(newPeer);

const registerCall = (call: MediaConnection) => {
callsRef.current[call.peer] = call;
call.on("stream", (remoteStream: MediaStream) => {
setRemoteStreams((prev) => ({ ...prev, [call.peer]: remoteStream }));
updateUser(call.peer, { stream: remoteStream });
});
call.on("error", (err) => {
console.error(`[useWebRTC] Call error with ${call.peer}:`, err);
});
call.on("close", () => {
if (callsRef.current[call.peer] === call) {
delete callsRef.current[call.peer];
}
setRemoteStreams((prev) => {
const next = { ...prev };
delete next[call.peer];
return next;
});
updateUser(call.peer, { stream: undefined });
});
};

const handleIncomingCall = (call: MediaConnection) => {
if (callsRef.current[call.peer]) {
call.close();
return;
}
const existingCall = callsRef.current[call.peer];
registerCall(call);
existingCall?.close();
if (pendingStream) {
call.answer(pendingStream);
} else {
Expand All @@ -302,11 +345,13 @@ export function useWebRTC(
// PeerJS connection events
newPeer.on("open", (id) => {
console.log(`[useWebRTC] PeerJS connected with ID: ${id}`);
setPeerReady(true);
});
newPeer.on("error", (err) => {
console.error("[useWebRTC] PeerJS error:", err);
});
newPeer.on("disconnected", () => {
setPeerReady(false);
console.warn("[useWebRTC] PeerJS disconnected, attempting reconnect...");
newPeer.reconnect();
});
Expand Down Expand Up @@ -354,7 +399,9 @@ export function useWebRTC(

return () => {
disposed = true;
setPeerReady(false);
newPeer.off("call", handleIncomingCall);
Object.values(callsRef.current).forEach((call) => call.close());
newPeer.destroy();
pendingStream?.getTracks().forEach((track) => track.stop());
if (localStreamRef.current === pendingStream) {
Expand All @@ -366,37 +413,18 @@ export function useWebRTC(
setLocalStream((currentStream) => currentStream === pendingStream ? null : currentStream);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, roomId]);
}, [userId, roomId, registerCall]);

// Call new users when they join
useEffect(() => {
if (!peer || !localStream) return;
if (!peer || !peerReady || !localStream) return;

users.forEach((user) => {
if (user.id !== userId && userId.localeCompare(user.id) < 0 && !callsRef.current[user.id]) {
const call = peer.call(user.id, localStream);
if (call) {
callsRef.current[user.id] = call;
call.on("stream", (remoteStream: MediaStream) => {
setRemoteStreams((prev) => ({ ...prev, [user.id]: remoteStream }));
updateUser(user.id, { stream: remoteStream });
});
call.on("error", (err) => {
console.error(`[useWebRTC] Call error with ${user.id}:`, err);
});
call.on("close", () => {
if (callsRef.current[user.id] === call) delete callsRef.current[user.id];
setRemoteStreams((prev) => {
const next = { ...prev };
delete next[user.id];
return next;
});
updateUser(user.id, { stream: undefined });
});
}
startCall(user.id);
}
});
}, [users, peer, localStream, userId, updateUser]);
}, [users, peer, peerReady, localStream, userId, startCall]);

const toggleScreenShare = async (isSharing: boolean, onStop?: () => void) => {
const stream = localStreamRef.current;
Expand Down Expand Up @@ -467,5 +495,5 @@ export function useWebRTC(
}
};

return { localStream, remoteStreams, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack, recoverMedia };
return { localStream, remoteStreams, toggleScreenShare, ensureAudioTrack, ensureVideoTrack, disableVideoTrack, recoverMedia, refreshMediaConnections };
}
25 changes: 23 additions & 2 deletions frontend/src/store/useRoomStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,29 @@ export const useRoomStore = create<RoomState>((set) => ({
users: [],
messages: [],
setMessages: (messages) => set({ messages }),
setUsers: (users) => set({ users }),
addUser: (user) => set((state) => ({ users: [...state.users, user] })),
setUsers: (users) => set((state) => {
const existingUsersById = new Map(state.users.map((user) => [user.id, user]));
const usersById = new Map<string, User>();
users.forEach((user) => {
usersById.set(user.id, {
...existingUsersById.get(user.id),
...usersById.get(user.id),
...user,
});
});
return { users: Array.from(usersById.values()) };
}),
addUser: (user) => set((state) => {
const existingUser = state.users.find((existing) => existing.id === user.id);
if (existingUser) {
return {
users: state.users.map((existing) =>
existing.id === user.id ? { ...existing, ...user } : existing
),
};
}
return { users: [...state.users, user] };
}),
removeUser: (socketId) => set((state) => ({ users: state.users.filter(u => u.id !== socketId) })),
updateUser: (socketId, updates) => set((state) => ({
users: state.users.map(u => u.id === socketId ? { ...u, ...updates } : u)
Expand Down
Loading