diff --git a/src/components/chat/hooks/useTts.ts b/src/components/chat/hooks/useTts.ts index 25020d10..fc4a6c33 100644 --- a/src/components/chat/hooks/useTts.ts +++ b/src/components/chat/hooks/useTts.ts @@ -1,119 +1,33 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { authenticatedFetch } from '../../../utils/api'; -import { voiceConfigHeaders } from '../../../hooks/useVoiceConfig'; +import { useCallback, useEffect, useState } from 'react'; +import { voicePlayer, voiceId, type VoiceSnapshot } from '../../../lib/voicePlayer'; -// Only one message speaks at a time across the whole app. -let stopActive: (() => void) | null = null; - -export type TtsState = 'idle' | 'loading' | 'playing'; +export type TtsState = VoiceSnapshot['state']; /** - * Tap-to-speak for a single message. Sends raw markdown to /api/voice/tts and plays - * the returned audio. Manual-gesture only (v1) to satisfy iOS autoplay. Exposes the - * last error (e.g. a backend timeout) so the control can surface it. + * Thin adapter over the app-level voicePlayer. Playback lives outside React (see + * lib/voicePlayer), so switching chats or re-rendering a message no longer cuts the + * audio off. This hook just reflects the player's state for one message and forwards taps. */ export function useTts(getText: () => string) { - const [state, setState] = useState('idle'); - const [error, setError] = useState(null); - const audioRef = useRef(null); - const urlRef = useRef(null); - const errorTimer = useRef | null>(null); + const content = getText(); + const id = voiceId(content); - const reset = useCallback(() => { - if (audioRef.current) { - audioRef.current.onended = null; - audioRef.current.onerror = null; - audioRef.current.pause(); - audioRef.current.src = ''; - audioRef.current = null; - } - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } - }, []); + const [snap, setSnap] = useState(() => voicePlayer.getSnapshot(id)); - const stop = useCallback(() => { - reset(); - setState('idle'); - if (stopActive) stopActive = null; - }, [reset]); - - const showError = useCallback((msg: string) => { - setError(msg); - if (errorTimer.current) clearTimeout(errorTimer.current); - errorTimer.current = setTimeout(() => setError(null), 6000); - }, []); - - // Cleanup on unmount: drop the global stop handler if it points at us, then reset. - useEffect( - () => () => { - if (stopActive === stop) stopActive = null; - if (errorTimer.current) clearTimeout(errorTimer.current); - reset(); - }, - [reset, stop], - ); - - const play = useCallback(async () => { - if (stopActive) stopActive(); - const text = getText(); - if (!text || !text.trim()) return; - setError(null); - - // Create + "unlock" the audio element synchronously inside the click gesture, - // so iOS Safari lets us play it after the async fetch resolves. - const audio = new Audio(); - audioRef.current = audio; - audio.onended = () => stop(); - audio.onerror = () => stop(); - try { - audio.play().catch(() => {}); - audio.pause(); - } catch { - /* unlock attempt; ignore */ - } - stopActive = stop; - setState('loading'); - - try { - const res = await authenticatedFetch('/api/voice/tts', { - method: 'POST', - body: JSON.stringify({ text }), - headers: voiceConfigHeaders(), + useEffect(() => { + const update = () => + setSnap((prev) => { + const next = voicePlayer.getSnapshot(id); + return prev.state === next.state && prev.error === next.error ? prev : next; }); - if (!res.ok) { - let msg = `Read-aloud failed (${res.status})`; - try { - const j = await res.json(); - if (j?.error) msg = String(j.error); - } catch { - /* non-JSON error body */ - } - throw new Error(msg); - } - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - if (audioRef.current !== audio) { - URL.revokeObjectURL(url); // stopped while loading; don't leak the blob URL - return; - } - urlRef.current = url; - audio.src = url; - audio.load(); - await audio.play(); - setState('playing'); - } catch (e) { - reset(); - setState('idle'); - showError(e instanceof Error ? e.message : 'Read-aloud failed'); - } - }, [getText, reset, stop, showError]); + update(); + return voicePlayer.subscribe(update); + }, [id]); const toggle = useCallback(() => { - if (state === 'playing' || state === 'loading') stop(); - else play(); - }, [state, play, stop]); + voicePlayer.unlock(); // synchronous, within the click gesture (iOS) + voicePlayer.toggle(content); + }, [content]); - return { state, toggle, error }; + return { state: snap.state, toggle, error: snap.error }; } diff --git a/src/lib/voicePlayer.ts b/src/lib/voicePlayer.ts new file mode 100644 index 00000000..f6b0015c --- /dev/null +++ b/src/lib/voicePlayer.ts @@ -0,0 +1,182 @@ +import { authenticatedFetch } from '../utils/api'; +import { voiceConfigHeaders } from '../hooks/useVoiceConfig'; + +// A single app-level audio player for read-aloud. It owns one