import React, { useEffect, useRef, useState } from 'react'; import { Mic, Plus } from 'lucide-react'; import { triggerHaptic } from '../../lib/api'; import { useVoiceInput } from '../../lib/useVoiceInput'; import { VoiceWave } from '../../components/VoiceWave'; export interface HoldToRecordFabProps { onTap: () => void; onTranscript: (text: string) => Promise | void; } const HOLD_DELAY_MS = 350; const CANCEL_DRAG_PX = 60; const STOP_TIMEOUT_MS = 1500; const TOAST_DURATION_MS = 2500; export const HoldToRecordFab: React.FC = ({ onTap, onTranscript }) => { const [isRecording, setIsRecording] = useState(false); const [isCancelZone, setIsCancelZone] = useState(false); const [liveTranscript, setLiveTranscript] = useState(''); const [toastMessage, setToastMessage] = useState(null); const isPressedRef = useRef(false); // A press that turned into a hold must not also count as a tap when the browser sends its click const suppressClickRef = useRef(false); const didEnterHoldRef = useRef(false); const fallbackHandledRef = useRef(false); const isCancelZoneRef = useRef(false); const finalizedRef = useRef(false); const startYRef = useRef(0); const transcriptRef = useRef(''); const holdTimerRef = useRef(null); const stopTimerRef = useRef(null); const toastTimerRef = useRef(null); const clearHoldTimer = () => { if (holdTimerRef.current !== null) { window.clearTimeout(holdTimerRef.current); holdTimerRef.current = null; } }; const clearStopTimer = () => { if (stopTimerRef.current !== null) { window.clearTimeout(stopTimerRef.current); stopTimerRef.current = null; } }; const showToast = (msg: string, sticky = false) => { if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); toastTimerRef.current = null; } setToastMessage(msg); if (sticky) return; toastTimerRef.current = window.setTimeout(() => { setToastMessage(null); toastTimerRef.current = null; }, TOAST_DURATION_MS); }; const finalizeRecording = async (text: string) => { if (finalizedRef.current) return; finalizedRef.current = true; clearStopTimer(); setLiveTranscript(''); const trimmed = text.trim(); if (!trimmed) { showToast('Nichts erkannt'); return; } try { await onTranscript(trimmed); triggerHaptic(25); showToast('Im Quick Dump gespeichert'); } catch { showToast('Fehler beim Speichern'); } }; // Own Whisper server (text after release) or the browser's recognition (text while speaking) const voice = useVoiceInput({ continuous: true, onText: (text, final) => { transcriptRef.current = text; if (!final) { setLiveTranscript(text); return; } void finalizeRecording(text); }, onError: (message) => { clearStopTimer(); setIsRecording(false); setIsCancelZone(false); setLiveTranscript(''); if (didEnterHoldRef.current && isPressedRef.current && !fallbackHandledRef.current) { // Recording could not start: open the normal capture sheet instead fallbackHandledRef.current = true; showToast(message); onTap(); return; } finalizedRef.current = true; showToast(message); }, }); useEffect(() => { return () => { clearHoldTimer(); clearStopTimer(); if (toastTimerRef.current !== null) { window.clearTimeout(toastTimerRef.current); } }; }, []); useEffect(() => { if (voice.state === 'transcribing') showToast('Wird erkannt…', true); }, [voice.state]); const startVoiceRecording = () => { if (voice.mode === 'off' || voice.mode === 'unsupported') { fallbackHandledRef.current = true; if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); onTap(); return; } transcriptRef.current = ''; finalizedRef.current = false; isCancelZoneRef.current = false; setLiveTranscript(''); setIsCancelZone(false); setIsRecording(true); triggerHaptic([15, 60, 25]); void voice.start(); }; const handlePointerDown = (e: React.PointerEvent) => { if (e.pointerType === 'mouse' && e.button !== 0) return; try { e.currentTarget.setPointerCapture?.(e.pointerId); } catch { // ignore if unsupported in environment } clearHoldTimer(); clearStopTimer(); isPressedRef.current = true; suppressClickRef.current = false; didEnterHoldRef.current = false; fallbackHandledRef.current = false; isCancelZoneRef.current = false; startYRef.current = e.clientY; holdTimerRef.current = window.setTimeout(() => { holdTimerRef.current = null; if (!isPressedRef.current) return; didEnterHoldRef.current = true; suppressClickRef.current = true; startVoiceRecording(); }, HOLD_DELAY_MS); }; const handlePointerMove = (e: React.PointerEvent) => { if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return; const deltaUp = startYRef.current - e.clientY; const inCancel = deltaUp > CANCEL_DRAG_PX; if (inCancel !== isCancelZoneRef.current) { isCancelZoneRef.current = inCancel; setIsCancelZone(inCancel); triggerHaptic(10); } }; const handlePointerUp = (e: React.PointerEvent) => { try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch { // ignore } if (!isPressedRef.current) return; isPressedRef.current = false; clearHoldTimer(); if (!didEnterHoldRef.current) { // A short tap opens on the click that follows. Opening here already would let that click land // on the freshly opened sheet's backdrop and close it again at once. return; } if (fallbackHandledRef.current) { return; } setIsRecording(false); setIsCancelZone(false); if (isCancelZoneRef.current) { finalizedRef.current = true; clearStopTimer(); setLiveTranscript(''); triggerHaptic(15); voice.cancel(); return; } // The browser's recognition sometimes never reports its end: save what was heard after a moment. // The own server answers when it is done (with its own time limit). if (voice.mode !== 'server') { stopTimerRef.current = window.setTimeout(() => { voice.cancel(); void finalizeRecording(transcriptRef.current); }, STOP_TIMEOUT_MS); } void voice.stop(); }; const handlePointerCancel = (e: React.PointerEvent) => { try { e.currentTarget.releasePointerCapture?.(e.pointerId); } catch { // ignore } if (!isPressedRef.current) return; isPressedRef.current = false; clearHoldTimer(); clearStopTimer(); if (didEnterHoldRef.current && !fallbackHandledRef.current) { finalizedRef.current = true; setIsRecording(false); setIsCancelZone(false); setLiveTranscript(''); voice.cancel(); } }; const displayedTranscript = liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript; return ( <> {isRecording && (
{isCancelZone ? (
🗑️ Zum Abbrechen loslassen
) : (
{voice.mode === 'server' && } Ich höre zu… (loslassen speichert)
{displayedTranscript ? (
{displayedTranscript}
) : (
Nach oben ziehen zum Abbrechen
)}
)}
)} {!isRecording && toastMessage && (
{toastMessage}
)} ); };