Spaces:
Runtime error
Runtime error
| import { useEffect, useState, useRef } from 'react'; | |
| import { LocalDB, Group, TranscriptionSegment } from '../lib/localdb'; | |
| import { translateText, apiHealth, preloadModel, sttStatus, transcribeAudio } from '../lib/api'; | |
| import { useAuth } from '../contexts/useAuth'; | |
| import { useNavigate } from './NavigationHooks'; | |
| import { Mic, MicOff, StopCircle, Copy, Check, QrCode, Users, Trash2, Download } from 'lucide-react'; | |
| import { QRCodeDisplay } from './QRCodeDisplay'; | |
| interface HostViewProps { | |
| groupId: string; | |
| } | |
| export function HostView({ groupId }: HostViewProps) { | |
| // ...existing code... | |
| // Subscribe to group updates so HostView always reflects latest source/target language | |
| useEffect(() => { | |
| const unsubGroup = LocalDB.onGroupUpdated(groupId, (updated) => { | |
| setGroup(updated); | |
| }); | |
| return () => unsubGroup(); | |
| }, [groupId]); | |
| // ...existing code... | |
| // Show only originals toggle | |
| const [showOriginalsOnly, setShowOriginalsOnly] = useState(false); | |
| // Malay-English model test state | |
| const [msEnAvailable, setMsEnAvailable] = useState<null | boolean>(null); | |
| const [msEnChecking, setMsEnChecking] = useState(false); | |
| // Parallel translation: track IDs currently being translated | |
| const [translatingIds, setTranslatingIds] = useState<Set<string>>(new Set()); | |
| const { user } = useAuth(); | |
| const navigate = useNavigate(); | |
| const [group, setGroup] = useState<Group | null>(null); | |
| const [isRecording, setIsRecording] = useState(false); | |
| // Segments always sorted by sequence number | |
| const [segments, setSegments] = useState<TranscriptionSegment[]>([]); | |
| const [memberCount, setMemberCount] = useState(0); | |
| const [showQR, setShowQR] = useState(false); | |
| const [copied, setCopied] = useState(false); | |
| const [interimText, setInterimText] = useState(''); | |
| const [apiOk, setApiOk] = useState<boolean | null>(null); | |
| // Advanced audio upload support | |
| const [sttAvailable, setSttAvailable] = useState(false); | |
| const [sttBusy, setSttBusy] = useState(false); | |
| // Removed unused diag variable | |
| const [retryingIds, setRetryingIds] = useState<Set<string>>(new Set()); | |
| const [bulkRetrying, setBulkRetrying] = useState(false); | |
| // Sentence-level buffering for mic recognition | |
| const [bufferedText, setBufferedText] = useState(''); | |
| const flushTimerRef = useRef<any>(null); | |
| const recognitionRef = useRef<any>(null); | |
| const isRecordingFlagRef = useRef(false); | |
| const sequenceNumberRef = useRef(0); | |
| const preloadAttemptedRef = useRef(false); | |
| const [modelReady, setModelReady] = useState<null | boolean>(null); | |
| useEffect(() => { | |
| if (!user) return; | |
| loadGroup(); | |
| loadSegments(); | |
| loadMemberCount(); | |
| // Preload model for selected target language when group changes | |
| if (group && apiOk) { | |
| preloadModel({ source_language: group.source_language, target_language: group.target_language }); | |
| } | |
| const unsubMembers = LocalDB.onMembersChanged(groupId, () => { | |
| loadMemberCount(); | |
| }); | |
| // poll API health | |
| let healthTimer: any; | |
| (async () => { | |
| const ok = await apiHealth(); | |
| setApiOk(ok); | |
| // Check if Whisper STT is available (backend should expose this) | |
| if (ok) { | |
| try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); } | |
| } | |
| // Auto-preload model when API is online and group is known | |
| if (ok && group && !preloadAttemptedRef.current) { | |
| preloadAttemptedRef.current = true; | |
| setModelReady(null); | |
| const warmed = await autoPreloadForGroup(group); | |
| setModelReady(warmed); | |
| } | |
| healthTimer = setInterval(async () => { | |
| const healthy = await apiHealth(); | |
| setApiOk(healthy); | |
| if (healthy) { | |
| try { setSttAvailable(await sttStatus()); } catch (e) { setSttAvailable(true); } | |
| } else { | |
| setSttAvailable(false); | |
| } | |
| // Re-attempt preload if API just came online | |
| if (healthy && group && !preloadAttemptedRef.current) { | |
| preloadAttemptedRef.current = true; | |
| setModelReady(null); | |
| const warmed = await autoPreloadForGroup(group); | |
| setModelReady(warmed); | |
| } | |
| }, 15000); | |
| })(); | |
| return () => { | |
| unsubMembers(); | |
| stopRecording(); | |
| if (healthTimer) clearInterval(healthTimer); | |
| }; | |
| }, [groupId, user]); | |
| // When group becomes available, kick off auto preload once | |
| useEffect(() => { | |
| if (group) { | |
| if (apiOk && !preloadAttemptedRef.current) { | |
| preloadAttemptedRef.current = true; | |
| (async () => { | |
| setModelReady(null); | |
| const warmed = await autoPreloadForGroup(group); | |
| setModelReady(warmed); | |
| })(); | |
| } | |
| } | |
| }, [group?.id, apiOk]); | |
| const autoPreloadForGroup = async (g: Group): Promise<boolean> => { | |
| let ok = await preloadModel({ source_language: g.source_language, target_language: g.target_language }); | |
| if (!ok && g.target_language === 'en' && (g.source_language === 'auto' || !g.source_language)) { | |
| const okMs = await preloadModel({ source_language: 'ms', target_language: 'en' }); | |
| const okId = await preloadModel({ source_language: 'id', target_language: 'en' }); | |
| ok = okMs || okId; | |
| } | |
| return ok; | |
| }; | |
| const loadGroup = async () => { | |
| const data = LocalDB.getGroupById(groupId); | |
| if (data) { | |
| setGroup(data); | |
| } else { | |
| navigate('/'); | |
| } | |
| }; | |
| const loadSegments = async () => { | |
| const data = LocalDB.getSegments(groupId); | |
| setSegments(data); | |
| sequenceNumberRef.current = data.length; | |
| }; | |
| const loadMemberCount = async () => { | |
| setMemberCount(LocalDB.getMemberCount(groupId)); | |
| }; | |
| const setRecording = (v: boolean) => { | |
| setIsRecording(v); | |
| isRecordingFlagRef.current = v; | |
| }; | |
| const startRecording = async () => { | |
| if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) { | |
| alert('Speech recognition is not supported in your browser. Please use Chrome or Edge.'); | |
| return; | |
| } | |
| const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; | |
| const recognition = new SpeechRecognition(); | |
| recognition.continuous = true; | |
| recognition.interimResults = true; | |
| const srcLang = (group?.source_language === 'auto' ? 'en' : group?.source_language || 'en').toLowerCase(); | |
| recognition.lang = srcLang === 'auto' ? 'en-US' | |
| : srcLang === 'ur' ? 'ur-PK' | |
| : srcLang === 'ar' ? 'ar-SA' | |
| : srcLang === 'es' ? 'es-ES' | |
| : srcLang === 'fr' ? 'fr-FR' | |
| : srcLang === 'de' ? 'de-DE' | |
| : srcLang === 'hi' ? 'hi-IN' | |
| : srcLang === 'zh' ? 'zh-CN' | |
| : srcLang === 'ms' ? 'ms-MY' | |
| : srcLang === 'id' ? 'id-ID' | |
| : 'en-US'; | |
| recognition.onresult = async (event: any) => { | |
| let interim = ''; | |
| let finalChunk = ''; | |
| for (let i = event.resultIndex; i < event.results.length; i++) { | |
| const transcript = (event.results[i][0].transcript || '').trim(); | |
| if (!transcript) continue; | |
| if (event.results[i].isFinal) { | |
| finalChunk += (finalChunk ? ' ' : '') + transcript; | |
| } else { | |
| interim += (interim ? ' ' : '') + transcript; | |
| } | |
| } | |
| if (interim) { | |
| setInterimText(interim); | |
| } else { | |
| setInterimText(''); | |
| } | |
| // Append final chunk to buffer and flush on sentence boundaries or heuristics | |
| if (finalChunk) { | |
| const combined = (bufferedText + ' ' + finalChunk).replace(/\s+/g, ' ').trim(); | |
| setBufferedText(combined); | |
| // Heuristics for when to flush the buffered sentence | |
| const endsSentence = /[.!?…]\s*$/.test(combined); | |
| const wordCount = combined.split(/\s+/).filter(Boolean).length; | |
| const overChars = combined.length >= 180; // max chars per segment | |
| const minWordsReached = wordCount >= 10; // avoid word-by-word for long speech | |
| // Clear any previous pending flush; we will schedule a new one below | |
| if (flushTimerRef.current) { | |
| clearTimeout(flushTimerRef.current); | |
| flushTimerRef.current = null; | |
| } | |
| const doFlush = async (forced = false) => { | |
| const txt = combined.trim(); | |
| if (!txt) return; | |
| // Skip ultra-short fillers unless sentence ended | |
| const wc = txt.split(/\s+/).filter(Boolean).length; | |
| // Immediate flush (on punctuation/length) keeps stricter rule; timer-based can be lenient | |
| if (!forced) { | |
| if (!endsSentence && wc < 3) return; // allow short 2-3 word utterances only if they feel like a sentence | |
| } else { | |
| if (wc < 1) return; // if timer fired and there's at least one word, flush | |
| } | |
| setBufferedText(''); | |
| await saveSegment(txt); | |
| }; | |
| if (endsSentence || overChars || (minWordsReached && !interim)) { | |
| await doFlush(false); | |
| } else { | |
| // Debounce-based flush after short pause | |
| flushTimerRef.current = setTimeout(async () => { | |
| await doFlush(true); | |
| }, 1500); | |
| } | |
| } | |
| }; | |
| recognition.onerror = (event: any) => { | |
| console.error('Speech recognition error:', event.error); | |
| if (event.error === 'no-speech') { | |
| return; | |
| } | |
| setRecording(false); | |
| }; | |
| recognition.onend = () => { | |
| if (isRecordingFlagRef.current) { | |
| try { | |
| recognition.start(); | |
| } catch (e) { | |
| setTimeout(() => { | |
| if (isRecordingFlagRef.current) { | |
| try { recognition.start(); } catch (_e) { /* ignore restart error */ } | |
| } | |
| }, 250); | |
| } | |
| } | |
| }; | |
| setRecording(true); | |
| recognition.start(); | |
| recognitionRef.current = recognition; | |
| }; | |
| const stopRecording = () => { | |
| if (recognitionRef.current) { | |
| setRecording(false); | |
| recognitionRef.current.stop(); | |
| recognitionRef.current = null; | |
| } | |
| setInterimText(''); | |
| setBufferedText(''); | |
| if (flushTimerRef.current) { | |
| clearTimeout(flushTimerRef.current); | |
| flushTimerRef.current = null; | |
| } | |
| }; | |
| // Save a segment and always add in order | |
| // Save segment and add to translation queue | |
| const saveSegment = async (text: string) => { | |
| if (!user || !group) return; | |
| // Add segment immediately with translation: null (processing) | |
| const tempSegment = LocalDB.addSegment({ | |
| groupId, | |
| originalText: text, | |
| translatedText: null, | |
| sequenceNumber: sequenceNumberRef.current, | |
| createdBy: user.id, | |
| }); | |
| setSegments((prev: TranscriptionSegment[]) => { | |
| const exists = prev.some((s: TranscriptionSegment) => s.id === tempSegment.id); | |
| if (exists) return prev; | |
| return [...prev, tempSegment].sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number); | |
| }); | |
| sequenceNumberRef.current++; | |
| // Start translation in background for this segment | |
| setTranslatingIds((prev) => new Set([...prev, tempSegment.id])); | |
| translateSegment(tempSegment); | |
| }; | |
| // Translate a segment in the background | |
| const translateSegment = async (segment: TranscriptionSegment) => { | |
| if (!group) return; | |
| let translatedText: string | null = null; | |
| if (group && group.source_language !== group.target_language) { | |
| const attempts: string[] = []; | |
| attempts.push(group.source_language === 'auto' ? 'auto' : group.source_language); | |
| if (!attempts.includes('ms')) attempts.push('ms'); | |
| if (!attempts.includes('id')) attempts.push('id'); | |
| let foundTranslation: string | null = null; | |
| for (const src of attempts) { | |
| const res = await translateText({ text: segment.original_text, source_language: src, target_language: group.target_language }); | |
| const isNoModel = typeof res === 'string' && /^\[No model for/i.test(res); | |
| const isEcho = !!res && res.trim() === segment.original_text.trim(); | |
| if (res && !isNoModel && !isEcho) { | |
| foundTranslation = res; | |
| break; | |
| } | |
| } | |
| if (foundTranslation) { | |
| translatedText = foundTranslation; | |
| } | |
| } else { | |
| translatedText = segment.original_text; | |
| } | |
| setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) => | |
| s.id === segment.id ? { ...s, translated_text: translatedText } : s | |
| ).sort((a: TranscriptionSegment, b: TranscriptionSegment) => a.sequence_number - b.sequence_number)); | |
| LocalDB.updateSegmentTranslation(groupId, segment.id, translatedText); | |
| setTranslatingIds((prev) => { | |
| const next = new Set(prev); | |
| next.delete(segment.id); | |
| return next; | |
| }); | |
| }; | |
| // Retry translation for a segment | |
| const retryTranslateOne = async (seg: TranscriptionSegment) => { | |
| if (!group) return; | |
| setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id))); | |
| setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) => | |
| s.id === seg.id ? { ...s, translated_text: null } : s | |
| )); | |
| setTranslatingIds((prev) => new Set([...prev, seg.id])); | |
| await translateSegment(seg); | |
| setRetryingIds((prev) => { | |
| const next = new Set(prev); | |
| next.delete(seg.id); | |
| return next; | |
| }); | |
| }; | |
| // Translate all segments currently missing a translation | |
| const retryTranslateMissing = async () => { | |
| if (!group) return; | |
| setBulkRetrying(true); | |
| try { | |
| const missing = segments.filter((seg) => !seg.translated_text || seg.translated_text === seg.original_text); | |
| await Promise.all(missing.map(async (seg) => { | |
| setRetryingIds((prev) => new Set(Array.from(prev).concat(seg.id))); | |
| setSegments((prev: TranscriptionSegment[]) => prev.map((s: TranscriptionSegment) => | |
| s.id === seg.id ? { ...s, translated_text: null } : s | |
| )); | |
| setTranslatingIds((prev) => new Set([...prev, seg.id])); | |
| await translateSegment(seg); | |
| setRetryingIds((prev) => { | |
| const next = new Set(prev); | |
| next.delete(seg.id); | |
| return next; | |
| }); | |
| })); | |
| } finally { | |
| setBulkRetrying(false); | |
| } | |
| }; | |
| const endSession = async () => { | |
| stopRecording(); | |
| LocalDB.setGroupActive(groupId, false); | |
| navigate('/'); | |
| }; | |
| // Upload removed | |
| const deleteSession = async () => { | |
| stopRecording(); | |
| if (confirm('Delete this session? This will remove all local data for it.')) { | |
| LocalDB.deleteGroup(groupId); | |
| navigate('/'); | |
| } | |
| }; | |
| const copyJoinCode = () => { | |
| if (group) { | |
| navigator.clipboard.writeText(group.join_code); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| } | |
| }; | |
| // Manual preload removed; handled automatically via autoPreloadForGroup() | |
| const handleExport = async () => { | |
| if (!group) return; | |
| const data = LocalDB.exportGroup(groupId); | |
| const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `${group.name.replace(/[^a-z0-9-]+/gi, '_') || 'session'}_${group.join_code}.json`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| URL.revokeObjectURL(url); | |
| }; | |
| if (!group) { | |
| return ( | |
| <div className="min-h-screen bg-gray-100 flex items-center justify-center"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> | |
| </div> | |
| ); | |
| } | |
| const speechSupported = typeof window !== 'undefined' && | |
| (("webkitSpeechRecognition" in window) || ("SpeechRecognition" in window)); | |
| return ( | |
| <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50"> | |
| <div className="max-w-4xl mx-auto p-4"> | |
| <div className="bg-white rounded-xl shadow-lg mb-4"> | |
| <div className="p-6 border-b border-gray-200"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h1 className="text-2xl font-bold text-gray-900">{group.name}</h1> | |
| <div className="flex items-center space-x-2"> | |
| <div className="flex items-center bg-gray-100 rounded-lg px-3 py-2"> | |
| <Users className="w-5 h-5 text-gray-600 mr-2" /> | |
| <span className="font-semibold text-gray-900">{memberCount}</span> | |
| </div> | |
| <div className="flex items-center rounded-lg px-3 py-2 border" title={apiOk === null ? 'Checking API' : apiOk ? 'API Online' : 'API Offline'}> | |
| <span className={`w-2 h-2 rounded-full mr-2 ${apiOk === null ? 'bg-gray-400 animate-pulse' : apiOk ? 'bg-green-600' : 'bg-red-600'}`}></span> | |
| <span className="text-sm text-gray-700">API</span> | |
| </div> | |
| {/* Toggle to show only original text */} | |
| <label className="ml-2 flex items-center text-xs cursor-pointer"> | |
| <input | |
| type="checkbox" | |
| checked={showOriginalsOnly} | |
| onChange={e => setShowOriginalsOnly(e.target.checked)} | |
| className="mr-1" | |
| /> | |
| Show only originals | |
| </label> | |
| {/* Malay-English model test button */} | |
| <button | |
| onClick={async () => { | |
| setMsEnChecking(true); | |
| setMsEnAvailable(null); | |
| try { | |
| const ok = await preloadModel({ source_language: 'ms', target_language: 'en' }); | |
| setMsEnAvailable(ok); | |
| } finally { | |
| setMsEnChecking(false); | |
| } | |
| }} | |
| className="ml-2 px-3 py-2 rounded-lg border text-xs" | |
| disabled={msEnChecking} | |
| title="Check if Malay-English model is available" | |
| > | |
| {msEnChecking ? 'Checking…' : 'Test Malay→English'} | |
| </button> | |
| {msEnAvailable !== null && ( | |
| <span className={`ml-2 text-xs font-bold ${msEnAvailable ? 'text-green-600' : 'text-red-600'}`}>{msEnAvailable ? 'Available' : 'Not available'}</span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center space-x-3"> | |
| <div className="flex-1 bg-blue-50 rounded-lg p-3 flex items-center justify-between"> | |
| <div> | |
| <span className="text-sm text-gray-600">Join Code:</span> | |
| <span className="ml-2 text-2xl font-mono font-bold text-blue-600"> | |
| {group.join_code} | |
| </span> | |
| </div> | |
| <button | |
| onClick={copyJoinCode} | |
| className="p-2 hover:bg-blue-100 rounded-lg transition-colors" | |
| > | |
| {copied ? ( | |
| <Check className="w-5 h-5 text-green-600" /> | |
| ) : ( | |
| <Copy className="w-5 h-5 text-blue-600" /> | |
| )} | |
| </button> | |
| </div> | |
| <button | |
| onClick={() => setShowQR(true)} | |
| className="bg-blue-600 text-white p-3 rounded-lg hover:bg-blue-700 transition-colors" | |
| > | |
| <QrCode className="w-6 h-6" /> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="p-6"> | |
| <div className="flex items-center justify-center space-x-4 mb-6"> | |
| {!isRecording ? ( | |
| <button | |
| onClick={startRecording} | |
| disabled={!speechSupported} | |
| className="bg-green-600 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-8 py-4 rounded-full font-semibold hover:bg-green-700 transition-colors flex items-center shadow-lg" | |
| > | |
| <Mic className="w-6 h-6 mr-2" /> | |
| Start Recording | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={stopRecording} | |
| className="bg-red-600 text-white px-8 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center shadow-lg animate-pulse" | |
| > | |
| <MicOff className="w-6 h-6 mr-2" /> | |
| Stop Recording | |
| </button> | |
| )} | |
| {/* Mic language selector removed for simplicity; we infer from group source */} | |
| <button | |
| onClick={endSession} | |
| className="bg-gray-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-gray-700 transition-colors flex items-center" | |
| > | |
| <StopCircle className="w-5 h-5 mr-2" /> | |
| End Session | |
| </button> | |
| {/* Preload button removed; model warm-up runs automatically */} | |
| <button | |
| onClick={deleteSession} | |
| className="bg-red-600 text-white px-6 py-4 rounded-full font-semibold hover:bg-red-700 transition-colors flex items-center" | |
| > | |
| <Trash2 className="w-5 h-5 mr-2" /> | |
| Delete Session | |
| </button> | |
| <button | |
| onClick={handleExport} | |
| className="bg-white border px-6 py-4 rounded-full font-semibold hover:bg-gray-50 transition-colors flex items-center" | |
| title="Export this session as JSON so you can delete safely if storage is full" | |
| > | |
| <Download className="w-5 h-5 mr-2" /> | |
| Export JSON | |
| </button> | |
| </div> | |
| {/* Advanced audio upload for Whisper STT */} | |
| {apiOk && sttAvailable && ( | |
| <div className="mt-4"> | |
| <label className="block text-sm text-gray-600 mb-1">Upload audio (Whisper)</label> | |
| <div className="flex items-center gap-2"> | |
| <input | |
| type="file" | |
| accept="audio/*" | |
| onChange={async (e) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| setSttBusy(true); | |
| try { | |
| // Use transcribeAudio from lib/api for Whisper backend | |
| const res = await transcribeAudio(file, (group?.source_language || undefined)); | |
| if (res && res.text && res.text.trim()) { | |
| await saveSegment(res.text.trim()); | |
| } | |
| } finally { | |
| setSttBusy(false); | |
| e.currentTarget.value = ''; | |
| } | |
| }} | |
| disabled={sttBusy} | |
| className="border rounded-lg px-3 py-2 text-sm" | |
| /> | |
| {sttBusy && <span className="text-xs text-gray-500">Transcribing…</span>} | |
| </div> | |
| <div className="text-xs text-gray-500 mt-1">Supported: common audio types; processed server-side via Whisper.</div> | |
| </div> | |
| )} | |
| {apiOk && ( | |
| <div className="mt-2 text-center"> | |
| <span className="text-xs text-gray-500"> | |
| Model: {modelReady === null ? 'warming…' : modelReady ? 'ready' : 'will load on first request'} | |
| </span> | |
| </div> | |
| )} | |
| {isRecording && ( | |
| <div className="text-center mb-4"> | |
| <div className="inline-flex items-center bg-red-100 text-red-700 px-4 py-2 rounded-full"> | |
| <span className="w-3 h-3 bg-red-600 rounded-full mr-2 animate-pulse"></span> | |
| Recording in progress | |
| </div> | |
| </div> | |
| )} | |
| {!isRecording && !speechSupported && ( | |
| <div className="text-center mb-4"> | |
| <div className="inline-flex items-center bg-yellow-100 text-yellow-800 px-4 py-2 rounded-full"> | |
| Your browser doesn't support Speech Recognition. Use Chrome/Edge or the manual input below. | |
| </div> | |
| </div> | |
| )} | |
| {/* Manual input fallback for quick testing or browsers without speech recognition */} | |
| <ManualInput onSubmit={saveSegment} disabled={!!isRecording && !!recognitionRef.current} /> | |
| </div> | |
| </div> | |
| <div className="bg-white rounded-xl shadow-lg p-6"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <h2 className="text-xl font-bold text-gray-900">Live Transcription</h2> | |
| <button | |
| onClick={retryTranslateMissing} | |
| disabled={bulkRetrying || segments.every((s) => s.translated_text && s.translated_text !== s.original_text)} | |
| className="text-sm px-3 py-1 rounded-md border disabled:opacity-50 hover:bg-gray-50" | |
| title="Retry translation for lines without English" | |
| > | |
| {bulkRetrying ? 'Translating…' : 'Translate missing'} | |
| </button> | |
| </div> | |
| <div className="space-y-3 max-h-96 overflow-y-auto"> | |
| <div className="text-xs text-gray-500 mb-2">Approx. storage used by this app: {Math.round(LocalDB.getStorageBytes()/1024)} KB</div> | |
| {segments | |
| .slice() | |
| .sort((a, b) => a.sequence_number - b.sequence_number) | |
| .map((segment) => ( | |
| <div key={segment.id} className="bg-gray-50 rounded-lg p-4"> | |
| <p className="text-gray-900">{segment.original_text}</p> | |
| {!showOriginalsOnly && ( | |
| <> | |
| {translatingIds.has(segment.id) || segment.translated_text === null ? ( | |
| <div className="mt-2 flex items-center gap-2"> | |
| <span className="text-xs text-blue-400 animate-pulse">Processing…</span> | |
| </div> | |
| ) : segment.translated_text && segment.translated_text !== segment.original_text ? ( | |
| <p className="text-blue-600 text-sm mt-2 italic">{segment.translated_text}</p> | |
| ) : ( | |
| <div className="mt-2 flex items-center gap-2"> | |
| <span className="text-xs text-gray-400">No English yet</span> | |
| <button | |
| onClick={() => retryTranslateOne(segment)} | |
| disabled={retryingIds.has(segment.id)} | |
| className="text-xs px-2 py-1 border rounded-md disabled:opacity-50 hover:bg-gray-50" | |
| > | |
| {retryingIds.has(segment.id) ? 'Translating…' : 'Translate'} | |
| </button> | |
| </div> | |
| )} | |
| </> | |
| )} | |
| <span className="text-xs text-gray-500 mt-2 block"> | |
| {new Date(segment.created_at).toLocaleTimeString()} | |
| </span> | |
| </div> | |
| ))} | |
| {interimText && ( | |
| <div className="bg-yellow-50 rounded-lg p-4 border-2 border-yellow-200"> | |
| <p className="text-gray-700 italic">{interimText}</p> | |
| <span className="text-xs text-gray-500 mt-2 block">Processing...</span> | |
| </div> | |
| )} | |
| {segments.length === 0 && !interimText && ( | |
| <div className="text-center py-12 text-gray-500"> | |
| <Mic className="w-12 h-12 mx-auto mb-3 text-gray-400" /> | |
| <p>Start recording to see live transcriptions appear here</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {showQR && group && ( | |
| <QRCodeDisplay | |
| joinCode={group.join_code} | |
| groupName={group.name} | |
| onClose={() => setShowQR(false)} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function ManualInput({ onSubmit, disabled }: { onSubmit: (text: string) => void | Promise<void>; disabled?: boolean }) { | |
| const [text, setText] = useState(''); | |
| const [busy, setBusy] = useState(false); | |
| const canSubmit = text.trim().length > 0 && !busy && !disabled; | |
| return ( | |
| <div className="mt-4"> | |
| <label className="block text-sm text-gray-600 mb-1">Type a line to add manually</label> | |
| <div className="flex gap-2"> | |
| <input | |
| type="text" | |
| value={text} | |
| onChange={(e) => setText(e.target.value)} | |
| onKeyDown={async (e) => { | |
| if (e.key === 'Enter' && canSubmit) { | |
| setBusy(true); | |
| await onSubmit(text.trim()); | |
| setText(''); | |
| setBusy(false); | |
| } | |
| }} | |
| className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" | |
| placeholder="Hello everyone..." | |
| disabled={disabled} | |
| /> | |
| <button | |
| onClick={async () => { | |
| if (!canSubmit) return; | |
| setBusy(true); | |
| await onSubmit(text.trim()); | |
| setText(''); | |
| setBusy(false); | |
| }} | |
| disabled={!canSubmit} | |
| className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-blue-700" | |
| > | |
| Add | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| } | |