mohrashid's picture
Upload 49 files
42d1288 verified
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>
);
}