All files / web/src/components/toys/euclid/hooks useHecklerCall.ts

0% Statements 0/207
0% Branches 0/1
0% Functions 0/1
0% Lines 0/207

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,
  }
}