diff --git a/.env.example b/.env.example index 8c878f38..eebb68f9 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,8 @@ GOOGLE_CLIENT_ID=your_google_client_id_here # Frontend Secrets (place in frontend/.env) VITE_GOOGLE_CLIENT_ID=your_google_client_id_here VITE_BASE_URL=http://localhost:1313 + +# Optional WebRTC TURN relay (recommended for reliable calls across restrictive networks) +VITE_WEBRTC_TURN_URL=turn:your-turn-server.example.com:3478 +VITE_WEBRTC_TURN_USERNAME=your_turn_username +VITE_WEBRTC_TURN_CREDENTIAL=your_turn_credential diff --git a/backend/websocket/team_websocket.go b/backend/websocket/team_websocket.go index 56512d23..10f2013b 100644 --- a/backend/websocket/team_websocket.go +++ b/backend/websocket/team_websocket.go @@ -516,6 +516,20 @@ func handleTeamJoin(room *TeamRoom, conn *websocket.Conn, message TeamMessage, c log.Printf("Team WebSocket write error in room %s: %v", roomKey, err) } } + + // Notify existing participants only after the joining client has acquired + // local media and explicitly announced that it is ready for WebRTC offers. + joinPayload := map[string]any{ + "type": "participantJoined", + "userId": client.UserID.Hex(), + "username": client.Username, + "teamId": client.TeamID.Hex(), + } + for _, r := range snapshotTeamRecipients(room, conn) { + if err := r.SafeWriteJSON(joinPayload); err != nil { + log.Printf("Team WebSocket participant join notification error in room %s: %v", roomKey, err) + } + } } // handleTeamChatMessage handles team chat messages @@ -1176,4 +1190,4 @@ func handleTeamLeave(room *TeamRoom, client *TeamClient, roomKey string) { } broadcastAll(room, payload) log.Printf("[handleTeamLeave] User %s left room %s", client.UserID.Hex(), roomKey) -} \ No newline at end of file +} diff --git a/frontend/src/Pages/TeamDebateRoom.tsx b/frontend/src/Pages/TeamDebateRoom.tsx index 8a0d42d8..3e57bfdc 100644 --- a/frontend/src/Pages/TeamDebateRoom.tsx +++ b/frontend/src/Pages/TeamDebateRoom.tsx @@ -3,11 +3,15 @@ import { useParams } from "react-router-dom"; import { useAtom } from "jotai"; import { userAtom } from "@/state/userAtom"; import { useUser } from "@/hooks/useUser"; -import { getTeamDebate } from "@/services/teamDebateService"; +import { + getTeamDebate, + type TeamDebate, +} from "@/services/teamDebateService"; import { Button } from "@/components/ui/button"; import JudgmentPopup from "@/components/JudgementPopup"; import SpeechTranscripts from "@/components/SpeechTranscripts"; import { getAuthToken } from "@/utils/auth"; +import { Mic, MicOff } from "lucide-react"; // Define debate phases as an enum (same as OnlineDebateRoom) enum DebatePhase { @@ -25,6 +29,7 @@ enum DebatePhase { // Define debate roles type DebateRole = "for" | "against"; +type MicControlState = "on" | "off" | "disabled"; type JudgmentData = { opening_statement: { @@ -106,6 +111,12 @@ interface WSMessage { team2Ready?: number; team1MembersCount?: number; team2MembersCount?: number; + team1Name?: string; + team2Name?: string; + team1ReadyStatus?: Record; + team2ReadyStatus?: Record; + countdown?: number; + assignedToTeam?: "Team1" | "Team2"; spectatorCount?: number; spectator?: { connectionId: string; @@ -130,6 +141,26 @@ const BASE_URL = (import.meta.env.VITE_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? window.location.origin; +const WEBRTC_ICE_SERVERS: RTCIceServer[] = [ + { urls: "stun:stun.l.google.com:19302" }, +]; + +const turnUrl = import.meta.env.VITE_WEBRTC_TURN_URL as string | undefined; +const turnUsername = import.meta.env.VITE_WEBRTC_TURN_USERNAME as + | string + | undefined; +const turnCredential = import.meta.env.VITE_WEBRTC_TURN_CREDENTIAL as + | string + | undefined; + +if (turnUrl && turnUsername && turnCredential) { + WEBRTC_ICE_SERVERS.push({ + urls: turnUrl, + username: turnUsername, + credential: turnCredential, + }); +} + // Function to extract JSON from response const extractJSON = (response: string): string => { const fenceRegex = /```(?:json)?\s*([\s\S]*?)\s*```/; @@ -159,7 +190,7 @@ const TeamDebateRoom: React.FC = () => { }, [user?.id, userFromHook?.id, currentUser?.id, isUserLoading, isAuthenticated]); // Debate state - const [debate, setDebate] = useState(null); + const [debate, setDebate] = useState(null); const [topic, setTopic] = useState(""); const [localRole, setLocalRole] = useState(null); const [peerRole, setPeerRole] = useState(null); @@ -191,6 +222,10 @@ const TeamDebateRoom: React.FC = () => { const localVideoRefs = useRef>(new Map()); const remoteVideoRefs = useRef>(new Map()); const localStreamRef = useRef(null); + const reconnectTimeoutsRef = useRef< + Map> + >(new Map()); + const reconnectPeerRef = useRef<(remoteUserId: string) => void>(() => {}); const debateStartedRef = useRef(false); // Track if debate has started to prevent popup reopening const currentUserIdRef = useRef(null); const myTeamIdRef = useRef(null); @@ -206,16 +241,24 @@ const TeamDebateRoom: React.FC = () => { >(new Map()); const [mediaError, setMediaError] = useState(null); const [isCameraOn, setIsCameraOn] = useState(true); + const isCameraOnRef = useRef(true); + const [isMicOn, setIsMicOn] = useState(true); + const isMicOnRef = useRef(true); + const isMicAvailableRef = useRef(false); // Timer state const [timer, setTimer] = useState(0); - const timerRef = useRef(null); + const timerRef = useRef | null>(null); // Speech recognition state - const [isListening, setIsListening] = useState(false); const [currentTranscript, setCurrentTranscript] = useState(""); const recognitionRef = useRef(null); + const isListeningRef = useRef(false); + const shouldListenRef = useRef(false); + const recognitionRestartTimeoutRef = useRef | null>(null); const [speechError, setSpeechError] = useState(null); const [speechTranscripts, setSpeechTranscripts] = useState<{ [key: string]: string; @@ -251,7 +294,7 @@ const TeamDebateRoom: React.FC = () => { ]; const toggleCamera = useCallback(async () => { - const shouldEnable = !isCameraOn; + const shouldEnable = !isCameraOnRef.current; // Acquire a stream if we're turning the camera back on after it was released if (shouldEnable && !localStreamRef.current) { @@ -260,6 +303,13 @@ const TeamDebateRoom: React.FC = () => { video: { width: 1280, height: 720 }, audio: true, }); + stream.getVideoTracks().forEach((track) => { + track.enabled = shouldEnable; + }); + stream.getAudioTracks().forEach((track) => { + track.enabled = + isMicAvailableRef.current && isMicOnRef.current; + }); localStreamRef.current = stream; setLocalStream(stream); @@ -286,13 +336,12 @@ const TeamDebateRoom: React.FC = () => { track.enabled = shouldEnable; }); + isCameraOnRef.current = shouldEnable; setIsCameraOn(shouldEnable); if (shouldEnable) { setMediaError(null); } - }, [currentUser?.id, isCameraOn, setIsCameraOn]); - - + }, [currentUser?.id]); const pendingCandidatesRef = useRef>(new Map()); const initiatedOffersRef = useRef>(new Set()); @@ -321,6 +370,12 @@ const TeamDebateRoom: React.FC = () => { const closePeerConnection = useCallback( (remoteUserId: string) => { + const reconnectTimeout = reconnectTimeoutsRef.current.get(remoteUserId); + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeoutsRef.current.delete(remoteUserId); + } + const pc = pcRefs.current.get(remoteUserId); if (pc) { try { @@ -358,9 +413,7 @@ const TeamDebateRoom: React.FC = () => { return undefined; } - const pc = new RTCPeerConnection({ - iceServers: [{ urls: "stun:stun.l.google.com:19302" }], - }); + const pc = new RTCPeerConnection({ iceServers: WEBRTC_ICE_SERVERS }); pcRefs.current.set(remoteUserId, pc); @@ -393,8 +446,36 @@ const TeamDebateRoom: React.FC = () => { pc.oniceconnectionstatechange = () => { const state = pc.iceConnectionState; - if (state === "failed" || state === "disconnected" || state === "closed") { - closePeerConnection(remoteUserId); + const reconnectTimeout = reconnectTimeoutsRef.current.get(remoteUserId); + + if (state === "connected" || state === "completed") { + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeoutsRef.current.delete(remoteUserId); + } + return; + } + + if ( + (state === "failed" || state === "disconnected") && + !reconnectTimeout + ) { + const delay = state === "failed" ? 0 : 5000; + const timeout = setTimeout(() => { + reconnectTimeoutsRef.current.delete(remoteUserId); + + if (pcRefs.current.get(remoteUserId) !== pc) return; + if ( + pc.iceConnectionState !== "failed" && + pc.iceConnectionState !== "disconnected" + ) { + return; + } + + closePeerConnection(remoteUserId); + reconnectPeerRef.current(remoteUserId); + }, delay); + reconnectTimeoutsRef.current.set(remoteUserId, timeout); } }; @@ -470,6 +551,13 @@ const TeamDebateRoom: React.FC = () => { [createPeerConnection, initiateOffer] ); + useEffect(() => { + reconnectPeerRef.current = ensurePeerConnection; + return () => { + reconnectPeerRef.current = () => {}; + }; + }, [ensurePeerConnection]); + useEffect(() => { remoteStreams.forEach((stream, userId) => { attachStreamToVideo(userId, stream); @@ -531,6 +619,65 @@ const TeamDebateRoom: React.FC = () => { return false; }, [debatePhase, localRole]); + const isMicAvailable = + isMyTurn && + debatePhase !== DebatePhase.Setup && + debatePhase !== DebatePhase.Finished; + const micControlState: MicControlState = !isMicAvailable + ? "disabled" + : isMicOn + ? "on" + : "off"; + const micControlLabel = + micControlState === "disabled" + ? "Mic Disabled" + : micControlState === "on" + ? "Mic On" + : "Mic Off"; + const micControlTitle = + micControlState === "disabled" + ? "Microphone disabled until your turn" + : micControlState === "on" + ? "Turn microphone off" + : "Turn microphone on"; + + useEffect(() => { + isMicAvailableRef.current = isMicAvailable; + const shouldEnableTrack = isMicAvailable && isMicOn; + localStreamRef.current?.getAudioTracks().forEach((track) => { + track.enabled = shouldEnableTrack; + }); + }, [isMicAvailable, isMicOn, localStream]); + + const toggleMicrophone = useCallback(() => { + if (!isMicAvailable) return; + + const stream = localStreamRef.current; + if (!stream) { + console.warn("toggleMicrophone called without an active local stream."); + return; + } + + const audioTracks = stream.getAudioTracks(); + if (audioTracks.length === 0) { + setMediaError( + "No microphone is available. Please check your audio device and permissions." + ); + return; + } + + const shouldEnable = !isMicOnRef.current; + audioTracks.forEach((track) => { + track.enabled = shouldEnable; + }); + + isMicOnRef.current = shouldEnable; + setIsMicOn(shouldEnable); + if (shouldEnable) { + setMediaError(null); + } + }, [isMicAvailable]); + // Fetch debate details - proceed if we have debateId and either user or token useEffect(() => { const fetchDebate = async () => { @@ -653,16 +800,15 @@ const TeamDebateRoom: React.FC = () => { }, [timer, debatePhase, isMyTurn, speechTranscripts, localRole, debateId]); useEffect(() => { - currentUserIdRef.current = currentUser?.id; - myTeamIdRef.current = myTeamId; - isTeam1Ref.current = isTeam1; - debatePhaseRef.current = debatePhase; -}, [currentUser?.id, myTeamId, isTeam1, debatePhase]); - + currentUserIdRef.current = currentUser?.id ?? null; + myTeamIdRef.current = myTeamId; + isTeam1Ref.current = isTeam1; + debatePhaseRef.current = debatePhase; + }, [currentUser?.id, myTeamId, isTeam1, debatePhase]); // Initialize WebSocket connection - only need token and debateId // User ID will be extracted from token on backend - + useEffect(() => { const token = getAuthToken(); if (!token || !debateId || !hasDeterminedTeam) { @@ -681,6 +827,10 @@ const TeamDebateRoom: React.FC = () => { }); let cancelled = false; + const reconnectTimeouts = reconnectTimeoutsRef.current; + const peerConnections = pcRefs.current; + const pendingCandidates = pendingCandidatesRef.current; + const initiatedOffers = initiatedOffersRef.current; const ensureMediaStream = async () => { try { @@ -694,6 +844,13 @@ const TeamDebateRoom: React.FC = () => { return; } + stream.getVideoTracks().forEach((track) => { + track.enabled = isCameraOnRef.current; + }); + stream.getAudioTracks().forEach((track) => { + track.enabled = + isMicAvailableRef.current && isMicOnRef.current; + }); localStreamRef.current = stream; setLocalStream(stream); @@ -720,15 +877,19 @@ const TeamDebateRoom: React.FC = () => { const ws = new WebSocket(wsUrl.toString()); wsRef.current = ws; - ws.onopen = () => { + ws.onopen = async () => { if (cancelled) { ws.close(); return; } console.log("Team debate WebSocket connected"); + await ensureMediaStream(); + + if (cancelled || ws.readyState !== WebSocket.OPEN) { + return; + } ws.send(JSON.stringify({ type: "join" })); - ensureMediaStream(); }; ws.onmessage = async (event) => { @@ -738,7 +899,6 @@ const TeamDebateRoom: React.FC = () => { const amTeam1 = isTeam1Ref.current; const currentMyTeamId = myTeamIdRef.current; const currentUserId = currentUserIdRef.current; - const currentPhase = debatePhaseRef.current; switch (data.type) { case "stateSync": { @@ -785,24 +945,24 @@ const TeamDebateRoom: React.FC = () => { if (data.team2MembersCount !== undefined) setTeam2MembersCount(data.team2MembersCount); // Update team names if provided (for late joiners) - if ((data as any).team1Name) { + if (data.team1Name) { if (amTeam1) { - setMyTeamName((data as any).team1Name); + setMyTeamName(data.team1Name); } else { - setOpponentTeamName((data as any).team1Name); + setOpponentTeamName(data.team1Name); } } - if ((data as any).team2Name) { + if (data.team2Name) { if (amTeam1) { - setOpponentTeamName((data as any).team2Name); + setOpponentTeamName(data.team2Name); } else { - setMyTeamName((data as any).team2Name); + setMyTeamName(data.team2Name); } } // Update individual player ready status (for late joiners) - if ((data as any).team1ReadyStatus) { - const team1Status = (data as any).team1ReadyStatus as Record; + if (data.team1ReadyStatus) { + const team1Status = data.team1ReadyStatus; setPlayerReadyStatus(prev => { const updated = new Map(prev); Object.entries(team1Status).forEach(([userId, ready]) => { @@ -811,8 +971,8 @@ const TeamDebateRoom: React.FC = () => { return updated; }); } - if ((data as any).team2ReadyStatus) { - const team2Status = (data as any).team2ReadyStatus as Record; + if (data.team2ReadyStatus) { + const team2Status = data.team2ReadyStatus; setPlayerReadyStatus(prev => { const updated = new Map(prev); Object.entries(team2Status).forEach(([userId, ready]) => { @@ -835,8 +995,8 @@ const TeamDebateRoom: React.FC = () => { // Update localReady if we have the user's ready status in stateSync if (currentUserId) { - const team1Status = (data as any).team1ReadyStatus as Record | undefined; - const team2Status = (data as any).team2ReadyStatus as Record | undefined; + const team1Status = data.team1ReadyStatus; + const team2Status = data.team2ReadyStatus; if (amTeam1 && team1Status && team1Status[currentUserId] !== undefined) { setLocalReady(team1Status[currentUserId]); } else if (!amTeam1 && team2Status && team2Status[currentUserId] !== undefined) { @@ -914,7 +1074,7 @@ const TeamDebateRoom: React.FC = () => { } case "countdownStart": { // Backend is starting countdown - show it to all users - const countdownValue = (data as any).countdown || 3; + const countdownValue = data.countdown || 3; console.log('✓✓✓ COUNTDOWN STARTED FROM BACKEND:', countdownValue); setCountdown(countdownValue); // Hide setup popup when countdown starts @@ -929,12 +1089,12 @@ const TeamDebateRoom: React.FC = () => { case "ready": { console.log("=== READY MESSAGE RECEIVED ==="); console.log("Received ready message:", data); - console.log("Current user:", currentUser?.id); + console.log("Current user:", currentUserId); console.log("Message userId:", data.userId); console.log("Message teamId:", data.teamId); - console.log("Message assignedToTeam:", (data as any).assignedToTeam); - console.log("isTeam1:", isTeam1); - console.log("myTeamId:", myTeamId); + console.log("Message assignedToTeam:", data.assignedToTeam); + console.log("isTeam1:", amTeam1); + console.log("myTeamId:", currentMyTeamId); console.log( "Team1Ready:", data.team1Ready, @@ -951,7 +1111,7 @@ const TeamDebateRoom: React.FC = () => { // CRITICAL: Verify the ready status is assigned to the correct team const messageTeamId = data.teamId; const expectedTeamId = currentMyTeamId; // Should be the same regardless of isTeam1 - const assignedTeam = (data as any).assignedToTeam; + const assignedTeam = data.assignedToTeam; // Update the ready status for the specific user who clicked if (data.userId === currentUserId && data.ready !== undefined) { @@ -981,9 +1141,8 @@ const TeamDebateRoom: React.FC = () => { setTeam2ReadyCount(data.team2Ready); } // CRITICAL: Update member counts from ready message - // Check both direct access and through (data as any) to handle type issues - const team1Count = data.team1MembersCount ?? (data as any).team1MembersCount; - const team2Count = data.team2MembersCount ?? (data as any).team2MembersCount; + const team1Count = data.team1MembersCount; + const team2Count = data.team2MembersCount; if (team1Count !== undefined && team1Count !== null) { console.log(`✓ Updating team1MembersCount to ${team1Count}`); @@ -1001,20 +1160,18 @@ const TeamDebateRoom: React.FC = () => { // Display what we're showing to the user // CRITICAL: Each user should see their own team correctly - // Use (data as any) to access fields that might not be in TypeScript interface - const dataAny = data as any; const myTeamReadyCount = amTeam1 - ? (data.team1Ready ?? dataAny.team1Ready) - : (data.team2Ready ?? dataAny.team2Ready); + ? (data.team1Ready ?? 0) + : (data.team2Ready ?? 0); const myTeamTotal = amTeam1 - ? (data.team1MembersCount ?? dataAny.team1MembersCount) - : (data.team2MembersCount ?? dataAny.team2MembersCount); + ? (data.team1MembersCount ?? 0) + : (data.team2MembersCount ?? 0); const oppReadyCount = amTeam1 - ? (data.team2Ready ?? dataAny.team2Ready) - : (data.team1Ready ?? dataAny.team1Ready); + ? (data.team2Ready ?? 0) + : (data.team1Ready ?? 0); const oppTeamTotal = amTeam1 - ? (data.team2MembersCount ?? dataAny.team2MembersCount) - : (data.team1MembersCount ?? dataAny.team1MembersCount); + ? (data.team2MembersCount ?? 0) + : (data.team1MembersCount ?? 0); console.log(`[Display] isTeam1=${amTeam1}, myTeamName=${myTeamName}`); console.log(`[Display] My Team (${myTeamName}) Ready: ${myTeamReadyCount}/${myTeamTotal}`); @@ -1102,6 +1259,18 @@ const TeamDebateRoom: React.FC = () => { } break; } + case "participantJoined": { + if (!data.userId || data.userId === currentUserId) { + break; + } + + // A previous offer may have been attempted while this participant + // was offline. Reset that stale attempt and negotiate again now that + // the backend has confirmed the target is ready for WebRTC offers. + closePeerConnection(data.userId); + ensurePeerConnection(data.userId); + break; + } case "offer": if ( data.targetUserId !== currentUserId || @@ -1206,31 +1375,44 @@ const TeamDebateRoom: React.FC = () => { cancelled = true; if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((track) => track.stop()); + localStreamRef.current = null; } if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } - pcRefs.current.forEach((pc) => pc.close()); + reconnectTimeouts.forEach((timeout) => clearTimeout(timeout)); + reconnectTimeouts.clear(); + peerConnections.forEach((pc) => pc.close()); + peerConnections.clear(); + pendingCandidates.clear(); + initiatedOffers.clear(); }; }, [ debateId, hasDeterminedTeam, createPeerConnection, + ensurePeerConnection, flushPendingCandidates, sendSignalMessage, closePeerConnection, - myTeamId, - currentUser?.id, ]); // Initialize Speech Recognition useEffect(() => { if (speechRecognitionDisabled) { + shouldListenRef.current = false; + isListeningRef.current = false; + if (recognitionRestartTimeoutRef.current) { + clearTimeout(recognitionRestartTimeoutRef.current); + recognitionRestartTimeoutRef.current = null; + } recognitionRef.current = null; return; } + let activeRecognition: SpeechRecognition | null = null; + const initializeSpeechRecognition = () => { if ( "SpeechRecognition" in window || @@ -1243,6 +1425,7 @@ const TeamDebateRoom: React.FC = () => { return; } const recognition = new SpeechRecognition(); + activeRecognition = recognition; recognitionRef.current = recognition; recognition.continuous = true; @@ -1250,7 +1433,7 @@ const TeamDebateRoom: React.FC = () => { recognition.lang = "en-US"; recognition.onstart = () => { - setIsListening(true); + isListeningRef.current = true; setSpeechError(null); }; @@ -1303,31 +1486,44 @@ const TeamDebateRoom: React.FC = () => { }; recognition.onend = () => { - setIsListening(false); + isListeningRef.current = false; if ( - isMyTurn && - debatePhase !== DebatePhase.Setup && - debatePhase !== DebatePhase.Finished - && !speechRecognitionDisabled + !shouldListenRef.current || + recognitionRef.current !== recognition ) { - setTimeout(() => { - if (recognitionRef.current) { - try { - recognitionRef.current.start(); - } catch (error) { - console.error("Error restarting speech recognition:", error); - } - } - }, 100); + return; + } + + if (recognitionRestartTimeoutRef.current) { + clearTimeout(recognitionRestartTimeoutRef.current); } + recognitionRestartTimeoutRef.current = setTimeout(() => { + recognitionRestartTimeoutRef.current = null; + if ( + !shouldListenRef.current || + recognitionRef.current !== recognition || + isListeningRef.current + ) { + return; + } + + try { + isListeningRef.current = true; + recognition.start(); + } catch (error) { + isListeningRef.current = false; + console.error("Error restarting speech recognition:", error); + } + }, 100); }; recognition.onerror = (event: Event) => { - setIsListening(false); + isListeningRef.current = false; console.error("Speech recognition error:", event); const errorEvent = event as Event & { error?: string }; if (errorEvent.error === "not-allowed") { + shouldListenRef.current = false; setSpeechRecognitionDisabled(true); setSpeechError( "Speech recognition is blocked. Please grant microphone permission or disable speech-to-text." @@ -1351,18 +1547,42 @@ const TeamDebateRoom: React.FC = () => { initializeSpeechRecognition(); return () => { - if (recognitionRef.current) { - recognitionRef.current.stop(); + shouldListenRef.current = false; + isListeningRef.current = false; + if (recognitionRestartTimeoutRef.current) { + clearTimeout(recognitionRestartTimeoutRef.current); + recognitionRestartTimeoutRef.current = null; + } + if (recognitionRef.current === activeRecognition) { + recognitionRef.current = null; + } + if (activeRecognition) { + activeRecognition.onstart = null; + activeRecognition.onresult = null; + activeRecognition.onend = null; + activeRecognition.onerror = null; + } + try { + activeRecognition?.stop(); + } catch { + // Recognition may already be stopped. } }; - }, [debatePhase, isMyTurn, currentUser?.id, currentUser?.displayName, speechRecognitionDisabled]); + }, [ + debatePhase, + isMyTurn, + currentUser?.id, + currentUser?.displayName, + speechRecognitionDisabled, + ]); // Start/stop speech recognition based on turn const startSpeechRecognition = useCallback(() => { + const recognition = recognitionRef.current; if ( - !recognitionRef.current || + !recognition || speechRecognitionDisabled || - isListening || + isListeningRef.current || debatePhase === DebatePhase.Setup || debatePhase === DebatePhase.Finished ) { @@ -1370,41 +1590,54 @@ const TeamDebateRoom: React.FC = () => { } try { - recognitionRef.current.start(); + isListeningRef.current = true; + recognition.start(); } catch (error) { + isListeningRef.current = false; console.error("Error starting speech recognition:", error); } - }, [isListening, debatePhase, speechRecognitionDisabled]); + }, [debatePhase, speechRecognitionDisabled]); const stopSpeechRecognition = useCallback(() => { - if (recognitionRef.current && isListening) { + const recognition = recognitionRef.current; + if (recognition && isListeningRef.current) { try { - recognitionRef.current.stop(); + isListeningRef.current = false; + recognition.stop(); } catch (error) { console.error("Error stopping speech recognition:", error); } } - }, [isListening]); + }, []); // Auto start/stop speech recognition based on turn useEffect(() => { - if ( - isMyTurn && - debatePhase !== DebatePhase.Setup && - debatePhase !== DebatePhase.Finished - ) { + const shouldListen = + isMicAvailable && + isMicOn && + !speechRecognitionDisabled; + + shouldListenRef.current = shouldListen; + + if (shouldListen) { startSpeechRecognition(); } else { stopSpeechRecognition(); } return () => { + shouldListenRef.current = false; + if (recognitionRestartTimeoutRef.current) { + clearTimeout(recognitionRestartTimeoutRef.current); + recognitionRestartTimeoutRef.current = null; + } stopSpeechRecognition(); }; }, [ - isMyTurn, - debatePhase, + isMicAvailable, + isMicOn, + speechRecognitionDisabled, startSpeechRecognition, stopSpeechRecognition, ]); @@ -1968,22 +2201,44 @@ const TeamDebateRoom: React.FC = () => { {isCurrentUser && " (You)"} {isCurrentUser && ( - + <> + + + )} {isCurrentUser && !isCameraOn ? ( @@ -2001,7 +2256,10 @@ const TeamDebateRoom: React.FC = () => { if (el) { if (isCurrentUser) { localVideoRefs.current.set(member.userId, el); - if (localStreamRef.current) { + if ( + localStreamRef.current && + el.srcObject !== localStreamRef.current + ) { el.srcObject = localStreamRef.current; } } else { @@ -2028,15 +2286,29 @@ const TeamDebateRoom: React.FC = () => {

Time: {formatTime(isMyTurn ? timer : phaseDurations[debatePhase] || 0)}

- {isMyTurn && debatePhase !== DebatePhase.Setup && debatePhase !== DebatePhase.Finished && ( + {isMicAvailable && (
-
- - {isListening ? "Recording & Speech Recognition Active" : "Waiting..."} +
+ + {micControlState === "on" + ? "Recording & Speech Recognition Active" + : "Microphone Off"}
)} - {/* Camera Toggle Button - Only show for current user's team */} + {/* Media controls - Only show for current user's team */} {currentUser && myTeamMembers.some(m => m.userId === currentUser.id) && (
+
)} diff --git a/frontend/src/components/SpeechTranscripts.tsx b/frontend/src/components/SpeechTranscripts.tsx index e2127db0..8c975a5b 100644 --- a/frontend/src/components/SpeechTranscripts.tsx +++ b/frontend/src/components/SpeechTranscripts.tsx @@ -3,11 +3,13 @@ import React from 'react'; interface SpeechTranscriptsProps { transcripts: { [key: string]: string }; currentPhase: string; + liveTranscript?: string; } const SpeechTranscripts: React.FC = ({ transcripts, currentPhase, + liveTranscript, }) => { const phases = [ 'openingFor', @@ -52,6 +54,8 @@ const SpeechTranscripts: React.FC = ({ {phases.map((phase) => { const transcript = transcripts[phase]; const isCurrentPhase = phase === currentPhase; + const displayedTranscript = + transcript || (isCurrentPhase ? liveTranscript : undefined); return (
= ({ )}
- {transcript ? ( + {displayedTranscript ? (
- {transcript} + {displayedTranscript}
) : (