"use client"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState, } from "react"; import { useSWRConfig } from "swr"; import { toast } from "@/components/primitives"; import { WsConnection } from "./connection"; import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types"; interface WsContextValue { status: WsStatus; connect: () => void; disconnect: () => void; sendText: (text: string) => void; sendBinary: (data: ArrayBufferLike) => void; /** Subscribe to a typed WS event. Returns unsubscribe function. */ on: ( eventType: E, handler: WsEventHandler, ) => () => void; /** Subscribe to binary PCM events. Returns unsubscribe function. */ onPcm: (handler: (chunk: PcmChunk) => void) => () => void; } const WsContext = createContext(null); export function WsProvider({ children, url, }: { children: ReactNode; url?: string; }) { const connRef = useRef(null); const [status, setStatus] = useState("disconnected"); const { mutate } = useSWRConfig(); // Tracks whether we've ever been connected — used to suppress the // "reconnecting" toast on initial page load. const wasConnected = useRef(false); // Event handler registry — Ref so listeners survive re-renders without reconnect // Using unknown as internal store; typed at the subscribe interface const handlersRef = useRef void>>>({}); const pcmHandlersRef = useRef void>>(new Set()); const handleJsonEvent = useCallback((json: string) => { try { const parsed = JSON.parse(json); const eventType = parsed.type as string; const data = parsed.data ?? parsed.state ?? parsed; const handlers = handlersRef.current; const eventHandlers = handlers[eventType as WsEventType]; if (eventHandlers && eventHandlers.size > 0) { eventHandlers.forEach((h) => h(data)); } } catch { // ignore parse errors } }, []); const handleBinaryEvent = useCallback((buffer: ArrayBuffer) => { if (buffer.byteLength < 4 || pcmHandlersRef.current.size === 0) return; const view = new DataView(buffer); const userIdHash = view.getUint32(0, true); const samples = new Int16Array(buffer, 4); const chunk: PcmChunk = { userIdHash, samples }; pcmHandlersRef.current.forEach((h) => h(chunk)); }, []); useEffect(() => { const conn = new WsConnection(url); connRef.current = conn; const unsubStatus = conn.onStatusChange((s) => { setStatus(s); // User feedback on WS lifecycle transitions. if (s === "connecting") { // Only toast if we were previously connected (i.e. a disconnect, // not the initial connect on page load). if (wasConnected.current) { toast({ title: "Reconnecting…", description: "WebSocket connection lost. Attempting to reconnect.", tone: "neutral", }); } wasConnected.current = false; } else if (s === "connected") { wasConnected.current = true; // After reconnect, force-refetch voice status immediately so // the UI converges faster instead of waiting up to 4s for SWR poll. void mutate(["voice-status"]); } else if (s === "error" && !wasConnected.current) { toast({ title: "Connection error", description: "WebSocket failed to connect. Retrying in the background.", tone: "vermilion", }); } }); const unsubEvent = conn.onEvent((event) => { if (event.type === "text") { handleJsonEvent(event.data); } else { handleBinaryEvent(event.data); } }); conn.connect(); return () => { conn.destroy(); connRef.current = null; unsubStatus(); unsubEvent(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [url, handleBinaryEvent, handleJsonEvent, mutate]); const subscribe = useCallback( (_eventType: E, handler: WsEventHandler) => { const eventType = _eventType as string; if (!handlersRef.current[eventType]) { handlersRef.current[eventType] = new Set(); } handlersRef.current[eventType].add(handler as (data: unknown) => void); return () => { handlersRef.current[eventType]?.delete( handler as (data: unknown) => void, ); }; }, [], ); const subscribePcm = useCallback((handler: (chunk: PcmChunk) => void) => { pcmHandlersRef.current.add(handler); return () => { pcmHandlersRef.current.delete(handler); }; }, []); const connect = useCallback(() => connRef.current?.connect(), []); const disconnect = useCallback(() => connRef.current?.disconnect(), []); const sendText = useCallback( (text: string) => connRef.current?.sendText(text), [], ); const sendBinary = useCallback( (data: ArrayBufferLike) => connRef.current?.sendBinary(data), [], ); return ( {children} ); } export function useWebSocket(): WsContextValue { const ctx = useContext(WsContext); if (!ctx) { throw new Error("useWebSocket must be used within a WsProvider"); } return ctx; }