Voice input — wake lock, stream reuse, auto-send, 60s limit, corner padding fix

This commit is contained in:
2026-04-26 13:56:02 -04:00
parent 29259e04e0
commit 8a436c4170
+128 -63
View File
@@ -1,19 +1,25 @@
'use client'; 'use client';
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { useStore } from '@/lib/store'; import { useStore } from '@/lib/store';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import type { Message } from '@/lib/api'; import type { Message } from '@/lib/api';
const MAX_RECORDING_SECONDS = 60;
export default function MessageInput() { export default function MessageInput() {
const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore(); const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore();
const [text, setText] = useState(''); const [text, setText] = useState('');
const [recording, setRecording] = useState(false); const [recording, setRecording] = useState(false);
const [transcribing, setTranscribing] = useState(false); const [transcribing, setTranscribing] = useState(false);
const [countdown, setCountdown] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]); const chunksRef = useRef<Blob[]>([]);
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mimeTypeRef = useRef<string>('audio/webm');
useEffect(() => { useEffect(() => {
textareaRef.current?.focus(); textareaRef.current?.focus();
@@ -26,8 +32,8 @@ export default function MessageInput() {
el.style.height = Math.min(el.scrollHeight, 160) + 'px'; el.style.height = Math.min(el.scrollHeight, 160) + 'px';
} }
async function send() { const send = useCallback(async (messageOverride?: string) => {
const message = text.trim(); const message = (messageOverride ?? text).trim();
if (!message || isLoading) return; if (!message || isLoading) return;
setText(''); setText('');
if (textareaRef.current) textareaRef.current.style.height = 'auto'; if (textareaRef.current) textareaRef.current.style.height = 'auto';
@@ -70,58 +76,106 @@ export default function MessageInput() {
setIsLoading(false); setIsLoading(false);
textareaRef.current?.focus(); textareaRef.current?.focus();
} }
}, [text, isLoading, currentId, addMessage, setCurrentId, setIsLoading, setConversations]);
async function acquireWakeLock() {
try {
if ('wakeLock' in navigator) {
wakeLockRef.current = await (navigator as Navigator & { wakeLock: { request: (type: string) => Promise<WakeLockSentinel> } }).wakeLock.request('screen');
}
} catch {
// Wake lock not supported or denied — not critical
}
}
function releaseWakeLock() {
wakeLockRef.current?.release();
wakeLockRef.current = null;
}
function stopRecording() {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
setCountdown(0);
releaseWakeLock();
mediaRecorderRef.current?.stop();
setRecording(false);
}
async function startRecording() {
try {
// Reuse existing stream if available, otherwise request new permission
if (!streamRef.current || streamRef.current.getTracks().every(t => t.readyState === 'ended')) {
streamRef.current = await navigator.mediaDevices.getUserMedia({ audio: true });
}
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: MediaRecorder.isTypeSupported('audio/mp4')
? 'audio/mp4'
: 'audio/ogg';
mimeTypeRef.current = mimeType;
chunksRef.current = [];
const mr = new MediaRecorder(streamRef.current, { mimeType });
mr.ondataavailable = e => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mr.onstop = async () => {
if (chunksRef.current.length === 0) return;
setTranscribing(true);
let transcript = '';
try {
const blob = new Blob(chunksRef.current, { type: mimeTypeRef.current });
const result = await api.transcribe(blob);
transcript = result.text.trim();
} catch (e) {
console.error('Transcription failed', e);
} finally {
setTranscribing(false);
}
if (transcript) {
// Auto-send after transcription
await send(transcript);
}
};
mr.start(1000);
mediaRecorderRef.current = mr;
setRecording(true);
setCountdown(MAX_RECORDING_SECONDS);
// Prevent screen lock during recording
await acquireWakeLock();
// Countdown timer — auto-stop at max duration
countdownRef.current = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
stopRecording();
return 0;
}
return prev - 1;
});
}, 1000);
} catch {
alert('Microphone access denied');
}
} }
async function toggleRecording() { async function toggleRecording() {
if (recording) { if (recording) {
// Stop recording stopRecording();
mediaRecorderRef.current?.stop();
setRecording(false);
} else { } else {
// Start recording await startRecording();
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
// Pick supported mime type
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
? 'audio/webm'
: MediaRecorder.isTypeSupported('audio/mp4')
? 'audio/mp4'
: 'audio/ogg';
const mr = new MediaRecorder(stream, { mimeType });
chunksRef.current = [];
mr.ondataavailable = e => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mr.onstop = async () => {
streamRef.current?.getTracks().forEach(t => t.stop());
if (chunksRef.current.length === 0) return;
setTranscribing(true);
try {
const blob = new Blob(chunksRef.current, { type: mimeType });
const { text: transcript } = await api.transcribe(blob);
if (transcript.trim()) {
setText(prev => prev ? prev + ' ' + transcript.trim() : transcript.trim());
setTimeout(() => autoResize(), 0);
}
} catch (e) {
console.error('Transcription failed', e);
} finally {
setTranscribing(false);
}
};
mr.start(1000); // collect data every second
mediaRecorderRef.current = mr;
setRecording(true);
} catch {
alert('Microphone access denied');
}
} }
} }
@@ -141,15 +195,15 @@ export default function MessageInput() {
style={{ style={{
borderTop: '1px solid var(--border)', borderTop: '1px solid var(--border)',
padding: '12px 16px', padding: '12px 16px',
paddingBottom: 'max(12px, env(safe-area-inset-bottom))', paddingBottom: 'max(16px, env(safe-area-inset-bottom))',
paddingLeft: 'max(16px, env(safe-area-inset-left))', paddingLeft: 'max(20px, env(safe-area-inset-left))',
paddingRight: 'max(16px, env(safe-area-inset-right))', paddingRight: 'max(20px, env(safe-area-inset-right))',
}} }}
> >
{/* Mic button — tap to start, tap to stop */} {/* Mic button */}
<button <button
onPointerUp={toggleRecording} onPointerUp={toggleRecording}
className="flex-shrink-0 rounded-lg flex items-center justify-center transition-all" className="flex-shrink-0 rounded-lg flex flex-col items-center justify-center transition-all"
style={{ style={{
width: '44px', width: '44px',
height: '44px', height: '44px',
@@ -159,9 +213,9 @@ export default function MessageInput() {
color: micColor, color: micColor,
touchAction: 'manipulation', touchAction: 'manipulation',
flexShrink: 0, flexShrink: 0,
position: 'relative',
}} }}
aria-label={recording ? 'Stop recording' : transcribing ? 'Transcribing...' : 'Start recording'} aria-label={recording ? 'Stop recording' : transcribing ? 'Transcribing...' : 'Start recording'}
title={recording ? 'Tap to stop' : 'Tap to record'}
> >
{transcribing ? ( {transcribing ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
@@ -170,12 +224,17 @@ export default function MessageInput() {
</circle> </circle>
</svg> </svg>
) : recording ? ( ) : recording ? (
// Square stop icon when recording <>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"> <svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor">
<rect width="14" height="14" rx="2"/> <rect width="14" height="14" rx="2"/>
</svg> </svg>
{countdown <= 15 && (
<span style={{ fontSize: '9px', lineHeight: 1, marginTop: '2px', opacity: 0.9 }}>
{countdown}s
</span>
)}
</>
) : ( ) : (
// Mic icon when idle
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 8a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V22h-2v-2.06A9 9 0 0 1 3 11h2z"/> <path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 8a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V22h-2v-2.06A9 9 0 0 1 3 11h2z"/>
</svg> </svg>
@@ -192,7 +251,13 @@ export default function MessageInput() {
value={text} value={text}
onChange={e => { setText(e.target.value); autoResize(); }} onChange={e => { setText(e.target.value); autoResize(); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={recording ? 'Recording... tap mic to stop' : transcribing ? 'Transcribing...' : 'Ask anything...'} placeholder={
recording
? `Recording... ${countdown > 0 ? countdown + 's left' : ''}`
: transcribing
? 'Transcribing...'
: 'Ask anything...'
}
rows={1} rows={1}
className="w-full block resize-none outline-none bg-transparent px-3 py-3 leading-relaxed min-w-0" className="w-full block resize-none outline-none bg-transparent px-3 py-3 leading-relaxed min-w-0"
style={{ style={{
@@ -207,7 +272,7 @@ export default function MessageInput() {
{/* Send button */} {/* Send button */}
<button <button
onPointerUp={send} onPointerUp={() => send()}
disabled={isLoading || !text.trim()} disabled={isLoading || !text.trim()}
className="flex-shrink-0 rounded-lg px-4 text-sm font-medium transition-opacity" className="flex-shrink-0 rounded-lg px-4 text-sm font-medium transition-opacity"
style={{ style={{