Voice input — wake lock, stream reuse, auto-send, 60s limit, corner padding fix
This commit is contained in:
+97
-32
@@ -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,59 +76,107 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleRecording() {
|
function releaseWakeLock() {
|
||||||
if (recording) {
|
wakeLockRef.current?.release();
|
||||||
// Stop recording
|
wakeLockRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecording() {
|
||||||
|
if (countdownRef.current) {
|
||||||
|
clearInterval(countdownRef.current);
|
||||||
|
countdownRef.current = null;
|
||||||
|
}
|
||||||
|
setCountdown(0);
|
||||||
|
releaseWakeLock();
|
||||||
mediaRecorderRef.current?.stop();
|
mediaRecorderRef.current?.stop();
|
||||||
setRecording(false);
|
setRecording(false);
|
||||||
} else {
|
}
|
||||||
// Start recording
|
|
||||||
try {
|
async function startRecording() {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
try {
|
||||||
streamRef.current = stream;
|
// 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 });
|
||||||
|
}
|
||||||
|
|
||||||
// Pick supported mime type
|
|
||||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
||||||
? 'audio/webm'
|
? 'audio/webm'
|
||||||
: MediaRecorder.isTypeSupported('audio/mp4')
|
: MediaRecorder.isTypeSupported('audio/mp4')
|
||||||
? 'audio/mp4'
|
? 'audio/mp4'
|
||||||
: 'audio/ogg';
|
: 'audio/ogg';
|
||||||
|
|
||||||
const mr = new MediaRecorder(stream, { mimeType });
|
mimeTypeRef.current = mimeType;
|
||||||
chunksRef.current = [];
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
const mr = new MediaRecorder(streamRef.current, { mimeType });
|
||||||
|
|
||||||
mr.ondataavailable = e => {
|
mr.ondataavailable = e => {
|
||||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
mr.onstop = async () => {
|
mr.onstop = async () => {
|
||||||
streamRef.current?.getTracks().forEach(t => t.stop());
|
|
||||||
if (chunksRef.current.length === 0) return;
|
if (chunksRef.current.length === 0) return;
|
||||||
|
|
||||||
setTranscribing(true);
|
setTranscribing(true);
|
||||||
|
let transcript = '';
|
||||||
try {
|
try {
|
||||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
const blob = new Blob(chunksRef.current, { type: mimeTypeRef.current });
|
||||||
const { text: transcript } = await api.transcribe(blob);
|
const result = await api.transcribe(blob);
|
||||||
if (transcript.trim()) {
|
transcript = result.text.trim();
|
||||||
setText(prev => prev ? prev + ' ' + transcript.trim() : transcript.trim());
|
|
||||||
setTimeout(() => autoResize(), 0);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Transcription failed', e);
|
console.error('Transcription failed', e);
|
||||||
} finally {
|
} finally {
|
||||||
setTranscribing(false);
|
setTranscribing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (transcript) {
|
||||||
|
// Auto-send after transcription
|
||||||
|
await send(transcript);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
mr.start(1000); // collect data every second
|
mr.start(1000);
|
||||||
mediaRecorderRef.current = mr;
|
mediaRecorderRef.current = mr;
|
||||||
setRecording(true);
|
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 {
|
} catch {
|
||||||
alert('Microphone access denied');
|
alert('Microphone access denied');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleRecording() {
|
||||||
|
if (recording) {
|
||||||
|
stopRecording();
|
||||||
|
} else {
|
||||||
|
await startRecording();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
@@ -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={{
|
||||||
|
|||||||
Reference in New Issue
Block a user