/** * 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 } from './voice/useVoiceController'; import { useCallSession } from './call/useCallSession'; import Waveform from './phone/primitives/Waveform'; import Aura from './phone/primitives/Aura'; import { createStreamingTts } from './call/streamTts'; import { clog, speakOwned, isCallFullDuplexEnabled } from './call/log'; import { useBargeInDetector } from './call/bargeIn'; // ── 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', }; function hpCallStateColor(state) { 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) { 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) { if (state === 'listening' || state === 'thinking') return 'listening'; if (state === 'speaking') return 'speaking'; if (state === 'muted') return 'muted'; return 'idle'; } function hpCallStateLabel(state, personaName) { 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 ''; } } const IconBase = ({ size = 20, color = 'currentColor', strokeWidth = 1.75, children, }) => ( {children} ); const IconPhoneEnd = (p) => ( ); const IconMic = (p) => ( ); const IconMicOff = (p) => ( ); const IconChat = (p) => ( ); const IconBack = (p) => ( ); const ControlBtn = ({ 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 = ({ 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 => (