From 8a436c4170965c9b5480d3e71728d51bb31e1874 Mon Sep 17 00:00:00 2001 From: Aaron Nelson Date: Sun, 26 Apr 2026 13:56:02 -0400 Subject: [PATCH] =?UTF-8?q?Voice=20input=20=E2=80=94=20wake=20lock,=20stre?= =?UTF-8?q?am=20reuse,=20auto-send,=2060s=20limit,=20corner=20padding=20fi?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/MessageInput.tsx | 191 ++++++++++++++++++++++++------------ 1 file changed, 128 insertions(+), 63 deletions(-) diff --git a/components/MessageInput.tsx b/components/MessageInput.tsx index 283dd98..ddb92cb 100644 --- a/components/MessageInput.tsx +++ b/components/MessageInput.tsx @@ -1,19 +1,25 @@ 'use client'; -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect, useCallback } from 'react'; import { useStore } from '@/lib/store'; import { api } from '@/lib/api'; import type { Message } from '@/lib/api'; +const MAX_RECORDING_SECONDS = 60; + export default function MessageInput() { const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore(); const [text, setText] = useState(''); const [recording, setRecording] = useState(false); const [transcribing, setTranscribing] = useState(false); + const [countdown, setCountdown] = useState(0); const textareaRef = useRef(null); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const streamRef = useRef(null); + const wakeLockRef = useRef(null); + const countdownRef = useRef | null>(null); + const mimeTypeRef = useRef('audio/webm'); useEffect(() => { textareaRef.current?.focus(); @@ -26,8 +32,8 @@ export default function MessageInput() { el.style.height = Math.min(el.scrollHeight, 160) + 'px'; } - async function send() { - const message = text.trim(); + const send = useCallback(async (messageOverride?: string) => { + const message = (messageOverride ?? text).trim(); if (!message || isLoading) return; setText(''); if (textareaRef.current) textareaRef.current.style.height = 'auto'; @@ -70,58 +76,106 @@ export default function MessageInput() { setIsLoading(false); 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 } }).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() { if (recording) { - // Stop recording - mediaRecorderRef.current?.stop(); - setRecording(false); + stopRecording(); } else { - // Start recording - 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'); - } + await startRecording(); } } @@ -141,15 +195,15 @@ export default function MessageInput() { style={{ borderTop: '1px solid var(--border)', padding: '12px 16px', - paddingBottom: 'max(12px, env(safe-area-inset-bottom))', - paddingLeft: 'max(16px, env(safe-area-inset-left))', - paddingRight: 'max(16px, env(safe-area-inset-right))', + paddingBottom: 'max(16px, env(safe-area-inset-bottom))', + paddingLeft: 'max(20px, env(safe-area-inset-left))', + paddingRight: 'max(20px, env(safe-area-inset-right))', }} > - {/* Mic button — tap to start, tap to stop */} + {/* Mic button */}