/** * CallOverlay.tsx — TSX port of the frontend/src/ui/phone/ design set. * * Maps 1:1 onto the design-only mockups in `phone/`: * tokens.jsx → HP_CALL + hpCallStateColor/Label (inlined below) * icons.jsx → components * controls.jsx → * avatar.jsx → * waveform.jsx → * call-modal.jsx → + the ModalPresentation wrapper * * Layered as a distinct "call mode" on top of the chat — it is NOT * the same thing as Voice mode: * 🎤 Voice = input method inside the chat * 📞 Call = a separate immersive session (this component) * * States (mirrors the designer's set): * connecting · listening · thinking · speaking · muted * (ended is intentionally short-lived — the overlay fades out.) * * This pass wires the visual shell. Real STT/TTS plumbing from * useVoiceController maps `listening/thinking/speaking` later. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useVoiceController, type VoiceState } from './voice/useVoiceController' import { useCallSession } from './call/useCallSession' import Waveform, { type WaveformMode } from './phone/primitives/Waveform' import Aura from './phone/primitives/Aura' import { createStreamingTts, type StreamingTts } from './call/streamTts' import { clog, speakOwned, isCallFullDuplexEnabled } from './call/log' import { useBargeInDetector } from './call/bargeIn' export type CallState = | 'dialing' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'muted' | 'ended' // ── Staged lifecycle timings. Numbers chosen so the user *feels* the // handshake instead of getting a hard screen flip: dial (ringing) → // connecting → listening (live). Dial is long enough for ~one full // PSTN ringback cadence (2 s on / 4 s off in the US) so the user // actually hears the phone "ring" before pickup. const DIAL_MS = 2200 // 📞 "calling…" — pulse-ring phase + one PSTN ring const CONNECT_MS = 500 // 🔵 "connecting…" — dots-pulse phase const END_FADE_MS = 220 // 🔴 end button → modal fade-out → onClose // ── Design tokens (ported from phone/tokens.jsx) ────────────────── const HP_CALL = { font: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif', fontTabular: 'ui-monospace, SFMono-Regular, "Roboto Mono", Menlo, monospace', backdrop: 'rgba(5, 5, 6, 0.72)', surface: '#0b0b0c', surface2: '#121214', border: 'rgba(255, 255, 255, 0.08)', text: 'rgba(255, 255, 255, 0.92)', text2: 'rgba(255, 255, 255, 0.55)', text3: 'rgba(255, 255, 255, 0.35)', accent: '#22d3ee', stateListening: '#22d3ee', stateThinking: '#a78bfa', stateSpeaking: '#10b981', stateError: '#f87171', end: '#ef4444', } as const function hpCallStateColor(state: CallState): string { switch (state) { case 'listening': return HP_CALL.stateListening case 'thinking': return HP_CALL.stateThinking case 'speaking': return HP_CALL.stateSpeaking case 'dialing': case 'connecting': return HP_CALL.accent case 'muted': case 'ended': return HP_CALL.text3 default: return HP_CALL.text2 } } // Map the voice-controller's internal machine onto our UI machine. // Returns null for states we don't want to reflect (OFF — overlay is // open so "off" is not a meaningful UI frame; render last mapped). function mapVoiceState(s: VoiceState): CallState | null { switch (s) { case 'LISTENING': return 'listening' case 'THINKING': return 'thinking' case 'SPEAKING': return 'speaking' case 'IDLE': return 'listening' case 'OFF': return null default: return null } } // Map the overlay's richer CallState onto the primitive Waveform's // four-valued mode enum. The primitive doesn't know about dialing / // connecting / thinking — those all fold to 'idle' (low ambient // amplitude, faint colour); only the three audible states drive // distinct waveform modes. function waveformModeFromCallState(state: CallState): WaveformMode { if (state === 'listening' || state === 'thinking') return 'listening' if (state === 'speaking') return 'speaking' if (state === 'muted') return 'muted' return 'idle' } function hpCallStateLabel(state: CallState, personaName: string): string { switch (state) { case 'dialing': return `calling ${personaName.toLowerCase()}…` case 'connecting': return 'connecting…' case 'listening': return 'listening' case 'thinking': return 'thinking' case 'speaking': return 'speaking' case 'muted': return 'microphone off' case 'ended': return 'call ended' default: return '' } } // ── Icons (ported from phone/icons.jsx) ─────────────────────────── type IconProps = { size?: number; color?: string } const IconBase: React.FC = ({ size = 20, color = 'currentColor', strokeWidth = 1.75, children, }) => ( {children} ) const IconPhoneEnd: React.FC = (p) => ( ) const IconMic: React.FC = (p) => ( ) const IconMicOff: React.FC = (p) => ( ) const IconChat: React.FC = (p) => ( ) const IconBack: React.FC = (p) => ( ) // ── ControlBtn (ported from phone/controls.jsx) ─────────────────── type Tone = 'neutral' | 'danger' | 'start' | 'accent' const ControlBtn: React.FC<{ size?: number tone?: Tone active?: boolean disabled?: boolean label?: string ariaLabel?: string onClick?: () => void children: React.ReactNode }> = ({ size = 48, tone = 'neutral', active = false, disabled, label, ariaLabel, onClick, children, }) => { const bg = tone === 'danger' ? HP_CALL.end : tone === 'start' ? HP_CALL.stateSpeaking : tone === 'accent' ? HP_CALL.accent : active ? 'rgba(255,255,255,0.92)' : HP_CALL.surface2 const fg = tone === 'danger' || tone === 'start' ? '#ffffff' : tone === 'accent' ? '#052c33' : active ? '#0b0b0c' : HP_CALL.text const glow = tone === 'danger' ? 'rgba(239, 68, 68, 0.45)' : tone === 'start' ? 'rgba(16, 185, 129, 0.45)' : tone === 'accent' ? 'rgba(34, 211, 238, 0.4)' : 'rgba(0, 0, 0, 0.3)' const border = tone === 'neutral' && !active ? `1px solid ${HP_CALL.border}` : 'none' return (
{label ? ( {label} ) : null}
) } // ── CallAvatar (ported from phone/avatar.jsx) ───────────────────── const CallAvatar: React.FC<{ size?: number state: CallState imageUrl?: string | null accentColor?: string | null }> = ({ size = 156, state, imageUrl = null, accentColor = null }) => { const stateColor = hpCallStateColor(state) const breathes = state === 'listening' || state === 'connecting' || state === 'speaking' || state === 'dialing' const showPulseRings = state === 'dialing' const haloOuter = size + 28 // seed + hue derivation now live inside the Aura primitive // (hashHue + moodOffset). This component only owns the halo + // ring decorations that depend on CallState. return (
{/* Expanding pulse rings — only while dialing. Three rings on staggered delays so the modal reads as "ringing", not just "spinning up". */} {showPulseRings && [0, 1, 2].map(i => (