feat(capture): hold mobile + button to record voice thoughts directly to inbox
This commit is contained in:
1 parent
6d6a35d28d
commit
eabb018c64
6 files changed
+649
-9
No files matched your search
@@ -0,0 +1,359 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Plus } from 'lucide-react';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
export interface HoldToRecordFabProps {
|
||||
onTap: () => void;
|
||||
onTranscript: (text: string) => Promise<void> | 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<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isCancelZone, setIsCancelZone] = useState(false);
|
||||
const [liveTranscript, setLiveTranscript] = useState('');
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
const isPressedRef = useRef(false);
|
||||
const didEnterHoldRef = useRef(false);
|
||||
const fallbackHandledRef = useRef(false);
|
||||
const isCancelZoneRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const hasEndedRef = useRef(false);
|
||||
const finalizedRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const transcriptRef = useRef('');
|
||||
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(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) => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
setToastMessage(msg);
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
toastTimerRef.current = null;
|
||||
}, TOAST_DURATION_MS);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finalizeRecording = async () => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
recognitionRef.current = null;
|
||||
|
||||
if (abortedRef.current) return;
|
||||
|
||||
const text = transcriptRef.current.trim();
|
||||
setLiveTranscript('');
|
||||
if (!text) {
|
||||
showToast('Nichts erkannt');
|
||||
return;
|
||||
}
|
||||
|
||||
triggerHaptic(25);
|
||||
showToast('Im Quick Dump gespeichert');
|
||||
try {
|
||||
await onTranscript(text);
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
|
||||
transcriptRef.current = '';
|
||||
abortedRef.current = false;
|
||||
isStoppingRef.current = false;
|
||||
hasEndedRef.current = false;
|
||||
finalizedRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let combined = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
combined += event.results[i][0].transcript;
|
||||
}
|
||||
transcriptRef.current = combined;
|
||||
setLiveTranscript(combined);
|
||||
};
|
||||
|
||||
recognition.onerror = (event: any) => {
|
||||
const errCode = event?.error;
|
||||
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
if (!fallbackHandledRef.current) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Mikrofon nicht freigegeben');
|
||||
onTap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
hasEndedRef.current = true;
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
setLiveTranscript('');
|
||||
setIsCancelZone(false);
|
||||
setIsRecording(true);
|
||||
triggerHaptic([15, 60, 25]);
|
||||
recognition.start();
|
||||
} catch {
|
||||
fallbackHandledRef.current = true;
|
||||
setIsRecording(false);
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
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;
|
||||
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;
|
||||
startVoiceRecording();
|
||||
}, HOLD_DELAY_MS);
|
||||
};
|
||||
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
|
||||
if (!didEnterHoldRef.current) {
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackHandledRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
|
||||
if (isCancelZoneRef.current) {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setLiveTranscript('');
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isStoppingRef.current = true;
|
||||
if (hasEndedRef.current || !recognitionRef.current) {
|
||||
void finalizeRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
stopTimerRef.current = window.setTimeout(() => {
|
||||
void finalizeRecording();
|
||||
}, STOP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!isPressedRef.current) return;
|
||||
isPressedRef.current = false;
|
||||
clearHoldTimer();
|
||||
clearStopTimer();
|
||||
|
||||
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||
abortedRef.current = true;
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const displayedTranscript =
|
||||
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRecording && (
|
||||
<div
|
||||
className={
|
||||
isCancelZone
|
||||
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err-border text-status-err shadow-panel pointer-events-none text-center'
|
||||
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
|
||||
}
|
||||
>
|
||||
{isCancelZone ? (
|
||||
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-main">
|
||||
Ich höre zu… (loslassen speichert)
|
||||
</div>
|
||||
{displayedTranscript ? (
|
||||
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
|
||||
{displayedTranscript}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRecording && toastMessage && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={
|
||||
isRecording
|
||||
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
|
||||
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
|
||||
}
|
||||
title="Schnell erfassen (+)"
|
||||
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Mic className="w-7 h-7 stroke-[2.5]" />
|
||||
) : (
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user