Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | import { useRef, useEffect, useCallback } from 'react' import type { MutableRefObject } from 'react' import type { ConstructionState } from '../types' import { useHecklerTrigger } from '../agent/useHecklerTrigger' import { DEFAULT_STALL_LINES } from '@/lib/voice/stallLines' import type { AttitudeId } from '../agent/attitudes/types' interface UseHecklerCallOptions { constructionRef: MutableRefObject<ConstructionState> playgroundMode: boolean | undefined isAuthorMode: boolean euclidVoice: { state: string dial: () => void hangUp: () => void stopRing: () => void activateSession: (stallText?: string) => void sendUserText: (text: string) => void } teacherConfig: { stallLines?: string[] } speakHecklerStallRef: MutableRefObject<(opts: { say: { en: string } }) => Promise<void>> stopAudio: () => void onAttitudeChange?: (attitudeId: AttitudeId) => void eventBusRef: MutableRefObject<{ subscribe: (cb: () => void) => () => void }> } /** * Manages the heckler call lifecycle: * - Watches for topology matches via useHecklerTrigger * - Pre-dials WebRTC on ringing * - Plays stall TTS while connecting * - Activates session when both WebRTC and stall are ready * - Cleans up on dismiss / match lost / call end */ export function useHecklerCall({ constructionRef, playgroundMode, isAuthorMode, euclidVoice, teacherConfig, speakHecklerStallRef, stopAudio, onAttitudeChange, eventBusRef, }: UseHecklerCallOptions) { const pendingActivateRef = useRef(false) const stallTextRef = useRef<string | null>(null) const stallDoneRef = useRef(true) // true = no stall playing (safe to activate) const hecklerPreDialRef = useRef(false) const heckler = useHecklerTrigger(constructionRef, !!playgroundMode && !isAuthorMode) /** Try to activate — only fires when BOTH session is ready AND stall TTS is done. */ const tryActivateRef = useRef(() => {}) tryActivateRef.current = () => { if (!pendingActivateRef.current) return if (euclidVoice.state !== 'preconnected') { console.log( '[heckler-activate] not yet — voiceState=%s, stallDone=%s', euclidVoice.state, stallDoneRef.current ) return } if (!stallDoneRef.current) { console.log('[heckler-activate] not yet — session ready but stall TTS still playing') return } console.log('[heckler-activate] both ready — activating now') pendingActivateRef.current = false hecklerPreDialRef.current = false euclidVoice.activateSession(stallTextRef.current ?? undefined) stallTextRef.current = null } // Pre-dial when heckler enters 'ringing': switch attitude and start WebRTC useEffect(() => { console.log( '[heckler-predial] effect: stage=%s, voiceState=%s, preDialRef=%s', heckler.stage, euclidVoice.state, hecklerPreDialRef.current ) if (heckler.stage !== 'ringing') return if (hecklerPreDialRef.current) { console.log('[heckler-predial] skipped — already initiated') return } if (euclidVoice.state !== 'idle' && euclidVoice.state !== 'error') { console.log('[heckler-predial] skipped — voice not idle (state=%s)', euclidVoice.state) return } console.log('[heckler-predial] initiating pre-dial') onAttitudeChange?.('heckler') hecklerPreDialRef.current = true pendingActivateRef.current = false stallTextRef.current = null stallDoneRef.current = true setTimeout(() => { console.log('[heckler-predial] setTimeout fired, calling dial()') euclidVoice.dial() }, 50) }, [heckler.stage, euclidVoice.state, euclidVoice.dial, onAttitudeChange]) // When the user clicks "Answer": activate immediately or play stalling TTS const handleHecklerAnswer = useCallback(() => { console.log( '[heckler-answer] clicked: voiceState=%s, preDialRef=%s, stallDone=%s', euclidVoice.state, hecklerPreDialRef.current, stallDoneRef.current ) euclidVoice.stopRing() heckler.answer() pendingActivateRef.current = true if (euclidVoice.state === 'preconnected') { console.log('[heckler-answer] preconnected — activating immediately') stallDoneRef.current = true hecklerPreDialRef.current = false pendingActivateRef.current = false euclidVoice.activateSession(stallTextRef.current ?? undefined) stallTextRef.current = null } else { console.log('[heckler-answer] not preconnected — stalling (voiceState=%s)', euclidVoice.state) const lines = teacherConfig.stallLines ?? DEFAULT_STALL_LINES const line = lines[Math.floor(Math.random() * lines.length)] stallTextRef.current = line stallDoneRef.current = false speakHecklerStallRef.current({ say: { en: line } }).then(() => { console.log('[heckler-stall] TTS finished') stallDoneRef.current = true tryActivateRef.current() }) if (euclidVoice.state === 'idle' || euclidVoice.state === 'error') { console.log('[heckler-answer] voice still idle — kicking off dial') hecklerPreDialRef.current = true onAttitudeChange?.('heckler') setTimeout(() => euclidVoice.dial(), 50) } } }, [heckler, euclidVoice, teacherConfig.stallLines, onAttitudeChange]) // When session reaches preconnected, try to activate useEffect(() => { if (euclidVoice.state === 'preconnected' && pendingActivateRef.current) { tryActivateRef.current() } }, [euclidVoice.state]) // Clean up pre-dial if heckler match is lost useEffect(() => { if (heckler.stage === 'idle' && hecklerPreDialRef.current) { console.log( '[heckler-cleanup] match lost — hanging up pre-dial, voiceState=%s', euclidVoice.state ) hecklerPreDialRef.current = false pendingActivateRef.current = false stallTextRef.current = null stallDoneRef.current = true stopAudio() if (euclidVoice.state !== 'idle') euclidVoice.hangUp() onAttitudeChange?.('teacher') } }, [heckler.stage, euclidVoice, onAttitudeChange, stopAudio]) // Reset heckler overlay when the voice call ends while stage is 'answered' useEffect(() => { if (heckler.stage === 'answered' && euclidVoice.state === 'idle') { console.log('[heckler-reset] call ended, resetting stage to idle') heckler.dismiss() hecklerPreDialRef.current = false pendingActivateRef.current = false stallTextRef.current = null stallDoneRef.current = true onAttitudeChange?.('teacher') } }, [heckler, euclidVoice.state, onAttitudeChange]) // Dismiss: clean up pre-connection and revert to teacher const handleHecklerDismiss = useCallback(() => { console.log('[heckler-dismiss] voiceState=%s', euclidVoice.state) heckler.dismiss() stopAudio() if (euclidVoice.state !== 'idle') euclidVoice.hangUp() hecklerPreDialRef.current = false pendingActivateRef.current = false stallTextRef.current = null stallDoneRef.current = true onAttitudeChange?.('teacher') }, [heckler, euclidVoice, onAttitudeChange, stopAudio]) // Trigger heckler topology check whenever a construction event fires useEffect(() => { if (!playgroundMode) return return eventBusRef.current.subscribe(() => { heckler.notifyConstructionChange() }) }, [playgroundMode, heckler.notifyConstructionChange]) return { heckler, hecklerPreDialRef, handleHecklerAnswer, handleHecklerDismiss, } } |