// Mobile Detection const isMobileApp = () => window.Capacitor !== undefined; console.log('Mobile App:', isMobileApp()); // API URL Configuration const getApiUrl = () => { if (isMobileApp()) { // For mobile apps, use your deployed API URL // Or local IP for testing: 'http://192.168.1.100:3000' return localStorage.getItem('apiUrl') || 'http://localhost:3000'; } if (window.location.protocol === 'file:') { // If the app is opened directly from the file system, point to the local server. return 'http://localhost:3000'; } // Use same-origin server URL for web-hosted pages. return window.location.origin; }; const API_URL = getApiUrl(); console.log('API URL:', API_URL); function isVoiceInputAllowed() { return window.isSecureContext || location.protocol === 'https:' || location.hostname === 'localhost' || location.hostname === '127.0.0.1' || location.hostname.endsWith('.local'); } // Initialize Speech Synthesis API const speechSynthesis = window.speechSynthesis; let availableVoices = []; let voicesLoaded = false; let mediaRecorder = null; let audioStream = null; let audioChunks = []; let isRecording = false; let autoSpeakAfterVoice = false; // DOM Elements const sourceText = document.getElementById('sourceText'); const translatedText = document.getElementById('translatedText'); const sourceLang = document.getElementById('sourceLang'); const targetLang = document.getElementById('targetLang'); const voiceBtn = document.getElementById('voiceBtn'); const clearSourceBtn = document.getElementById('clearSourceBtn') || document.getElementById('clearBtn'); const speakBtn = document.getElementById('speakBtn'); const copyBtn = document.getElementById('copyBtn'); const swapBtn = document.getElementById('swapBtn'); const voiceStatus = document.getElementById('voiceStatus'); const microphoneHint = document.querySelector('.microphone-hint'); const ttsNotice = document.getElementById('ttsNotice'); const statusMessage = document.getElementById('statusMessage') || document.getElementById('status'); const charCount = document.getElementById('charCount'); const copyFeedback = document.getElementById('copyFeedback'); const devLogBody = document.getElementById('devLogBody'); const clearLogBtn = document.getElementById('clearLogBtn'); // Language codes for speech synthesis const langMap = { 'en': 'en-US', 'es': 'es-ES', 'fr': 'fr-FR', 'de': 'de-DE', 'pt': 'pt-BR', 'it': 'it-IT', 'nl': 'nl-NL', 'ru': 'ru-RU', 'ar': 'ar-SA', 'ja': 'ja-JP', 'ko': 'ko-KR', 'zh': 'zh-CN', 'hi': 'hi-IN', 'ta': 'ta-IN', 'te': 'te-IN', 'ml': 'ml-IN', 'pa': 'pa-IN', 'kn': 'kn-IN', 'id': 'id-ID', 'vi': 'vi-VN', 'tr': 'tr-TR' }; const remoteTtsLanguages = new Set(['ta', 'te', 'ml', 'pa', 'kn']); // Initialize document.addEventListener('DOMContentLoaded', () => { setupEventListeners(); // Check for HTTPS/secure-context requirement for voice input if (!isVoiceInputAllowed() && !isMobileApp()) { console.warn('Voice input requires a secure context. Access via https://localhost:3443 for full functionality.'); } // Check voice input support const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder); if (!voiceSupportAvailable) { console.warn('Voice input not supported in this browser.'); if (voiceBtn) { voiceBtn.disabled = true; voiceBtn.title = 'Voice input not supported in this browser'; voiceBtn.style.opacity = '0.5'; } if (microphoneHint) { microphoneHint.style.display = 'none'; } } else { if (voiceBtn) { voiceBtn.disabled = false; voiceBtn.style.opacity = '1'; voiceBtn.classList.add('active'); } if (microphoneHint) { microphoneHint.textContent = 'โœ“ Microphone is supported. Click Voice Input and grant microphone access when your browser prompts.'; microphoneHint.style.display = 'block'; } } // Check speech synthesis support if (!speechSynthesis) { console.warn('Speech synthesis not supported in this browser.'); if (speakBtn) { speakBtn.disabled = true; speakBtn.title = 'Text-to-speech not supported in this browser'; speakBtn.style.opacity = '0.5'; } } else { loadVoices(); } verifyServerReachability(); checkMicrophonePermission(); showStatus('Ready to translate!', 'success'); }); // Setup Event Listeners function setupEventListeners() { const bind = (element, event, handler) => { if (element) { element.addEventListener(event, handler); } }; bind(sourceText, 'input', handleTextInput); bind(voiceBtn, 'click', handleVoiceInput); bind(clearLogBtn, 'click', () => { if (devLogBody) devLogBody.innerHTML = ''; }); bind(clearSourceBtn, 'click', clearInput); bind(speakBtn, 'click', speakTranslation); bind(copyBtn, 'click', copyTranslation); bind(swapBtn, 'click', swapLanguages); bind(sourceLang, 'change', translateOnChange); bind(targetLang, 'change', translateOnChange); } // Check microphone permission state (if supported) and update UI async function checkMicrophonePermission() { if (!navigator.permissions) return; try { const status = await navigator.permissions.query({ name: 'microphone' }); if (status.state === 'denied') { if (microphoneHint) { // If the page was loaded over HTTP (not secure), recommend using HTTPS localhost:3443 const suggestedUrl = (location.protocol === 'http:') ? 'https://localhost:3443' : 'http://localhost:3000'; microphoneHint.innerHTML = `๐Ÿ”’ Microphone blocked for this site. To allow:\nโ€ข Click the lock icon in the address bar โ†’ Site settings โ†’ Microphone โ†’ Allow\nโ€ข Or open this app via ${suggestedUrl} and re-grant permissions.` .replace(/\n/g, '
'); microphoneHint.style.display = 'block'; } if (voiceBtn) { voiceBtn.disabled = false; voiceBtn.title = 'Microphone permission blocked. Click to retry permission prompt.'; voiceBtn.classList.add('active'); } logDev('Microphone permission state: denied'); } else if (status.state === 'prompt') { if (microphoneHint) { microphoneHint.textContent = 'Click Voice Input and allow microphone access when prompted.'; microphoneHint.style.display = 'block'; } if (voiceBtn) { voiceBtn.disabled = false; voiceBtn.classList.add('active'); } logDev('Microphone permission state: prompt'); } else if (status.state === 'granted') { if (microphoneHint) { microphoneHint.textContent = 'Microphone access granted. Click Voice Input to start.'; microphoneHint.style.display = 'block'; } if (voiceBtn) { voiceBtn.disabled = false; voiceBtn.style.opacity = '1'; voiceBtn.style.display = ''; voiceBtn.classList.add('active'); } logDev('Microphone permission state: granted'); } // Re-run when permission state changes status.onchange = () => { logDev(`Microphone permission changed: ${status.state}`); checkMicrophonePermission(); }; } catch (e) { // Some browsers do not support querying microphone via Permissions API return; } } async function verifyServerReachability() { if (!API_URL) { showStatus('Server URL is not configured.', 'error'); return; } try { const response = await fetch(`${API_URL}/api/health`, { cache: 'no-store' }); if (response.ok) { const data = await response.json(); showStatus(`Server reachable: ${data.message}`, 'success'); logDev(`Server reachable: ${data.message}`); return; } throw new Error(`HTTP ${response.status}`); } catch (error) { console.error('Server reachability check failed:', error); const openUrl = (location.protocol === 'http:') ? 'https://localhost:3443' : 'http://localhost:3000'; showStatus(`Cannot reach the app server. Open the app via ${openUrl}`, 'error'); logDev(`Server reachability failed: ${error?.message || error}`); } } // Handle Text Input function handleTextInput(e) { const text = e.target.value; if (charCount) { charCount.textContent = text.length; } if (text.trim().length > 0) { debounceTranslate(); } else { translatedText.textContent = ''; } } // Debounce Translation let translateTimeout; function debounceTranslate() { clearTimeout(translateTimeout); translateTimeout = setTimeout(() => { translateText(); }, 500); } // Translate Text async function translateText() { const text = sourceText.value.trim(); if (!text) return; const source = sourceLang.value; const target = targetLang.value; if (source === target) { translatedText.textContent = text; return; } try { showStatus('Translating...', 'loading'); const response = await fetch(`${API_URL}/api/translate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text, sourceLang: source, targetLang: target }) }); if (!response.ok) { throw new Error('Translation failed'); } const data = await response.json(); translatedText.textContent = data.translatedText; showStatus('Translation complete!', 'success'); if (autoSpeakAfterVoice) { autoSpeakAfterVoice = false; speakTranslation(); } } catch (error) { console.error('Translation error:', error); // Fallback to client-side translation using simple mapping translatedText.textContent = text; // Fallback: show original text showStatus('Using direct translation (server unavailable)', 'error'); } } // Handle Voice Input async function handleVoiceInput() { if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { showStatus('Voice recording is not supported in this browser.', 'error'); return; } // If permissions API is available, check microphone state before prompting if (navigator.permissions) { try { const perm = await navigator.permissions.query({ name: 'microphone' }); if (perm.state === 'denied') { const msg = 'Microphone permission is blocked. Open your browser site settings and allow microphone for this site.'; showStatus(msg, 'error'); voiceStatus.textContent = 'โŒ Permission blocked'; if (microphoneHint) { microphoneHint.innerHTML = '๐Ÿ”’ Microphone blocked. To allow:
โ€ข Click the lock icon in the address bar โ†’ Site settings โ†’ Microphone โ†’ Allow
โ€ข Reload the page and retry.'; microphoneHint.style.display = 'block'; } if (voiceBtn) { voiceBtn.disabled = false; voiceBtn.style.opacity = '1'; } return; } } catch (e) { // ignore and continue to request permission (some browsers don't support querying) } } if (isRecording) { if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); } isRecording = false; voiceBtn.classList.remove('listening'); voiceStatus.textContent = '๐Ÿ”„ Processing voice...'; return; } try { audioChunks = []; voiceStatus.textContent = 'โณ Requesting microphone access...'; if (microphoneHint) { microphoneHint.textContent = 'Browser will prompt for microphone access. Click "Allow" to grant permission.'; microphoneHint.style.display = 'block'; } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioStream = stream; const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'; mediaRecorder = new MediaRecorder(stream, { mimeType }); mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); } }; mediaRecorder.onstop = async () => { const blob = new Blob(audioChunks, { type: mediaRecorder.mimeType || 'audio/webm' }); if (audioStream) { audioStream.getTracks().forEach(track => track.stop()); audioStream = null; } await transcribeAudio(blob); }; mediaRecorder.start(); isRecording = true; voiceBtn.classList.add('listening'); voiceStatus.textContent = '๐ŸŽค Listening...'; showStatus('Listening for voice input...', 'success'); logDev('Started recording, awaiting audio data'); } catch (error) { console.error('Microphone permission denied:', error); const isPermissionError = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; let errorMessage = ''; let hintMessage = ''; if (isPermissionError) { errorMessage = 'Microphone permission denied. Grant access in your browser settings.'; hintMessage = '๐Ÿ”’ Microphone blocked. To allow access:\nโ€ข Chrome/Edge/Brave: Click the lock icon โ†’ Microphone โ†’ Allow\nโ€ข Firefox: Preferences โ†’ Privacy โ†’ Permissions โ†’ Microphone โ†’ Allow\nโ€ข Safari: System Preferences โ†’ Security & Privacy โ†’ Microphone โ†’ Allow'; } else { errorMessage = 'Microphone access failed. Check browser and OS privacy settings.'; hintMessage = 'Unable to access microphone. Verify:\nโ€ข Browser has permission to access microphone\nโ€ข OS allows the browser to use the microphone\nโ€ข No other app is using the microphone'; } showStatus(errorMessage, 'error'); voiceStatus.textContent = `โŒ ${isPermissionError ? 'Permission denied' : 'Microphone error'}`; if (microphoneHint) { microphoneHint.innerHTML = hintMessage.replace(/\n/g, '
'); microphoneHint.style.display = 'block'; } logDev(`handleVoiceInput error: ${error?.name || error?.message || error}`); } } async function transcribeAudio(blob) { try { const response = await fetch(`${API_URL}/api/transcribe`, { method: 'POST', headers: { 'Content-Type': blob.type || 'audio/webm' }, body: blob }); if (!response.ok) { throw new Error('Transcription failed'); } const data = await response.json(); const transcript = data.transcript || ''; sourceText.value = transcript; if (charCount) { charCount.textContent = transcript.length; } if (voiceStatus) { voiceStatus.textContent = 'โœ“ Voice captured'; } autoSpeakAfterVoice = true; await translateText(); } catch (error) { console.error('Voice transcription error:', error); showStatus('Voice transcription failed', 'error'); voiceStatus.textContent = 'โŒ Voice transcription failed'; } } function loadVoices() { if (!speechSynthesis || typeof speechSynthesis.getVoices !== 'function') return; availableVoices = speechSynthesis.getVoices(); voicesLoaded = availableVoices.length > 0; if (!voicesLoaded) { speechSynthesis.onvoiceschanged = () => { availableVoices = speechSynthesis.getVoices(); voicesLoaded = availableVoices.length > 0; }; } } function getPreferredVoice(languageCode) { if (!speechSynthesis || !availableVoices.length) return null; const normalizedCode = languageCode.toLowerCase(); const directMatch = availableVoices.find(voice => voice.lang && voice.lang.toLowerCase() === normalizedCode); if (directMatch) return directMatch; const languageMatch = availableVoices.find(voice => voice.lang && voice.lang.toLowerCase().startsWith(normalizedCode)); if (languageMatch) return languageMatch; return availableVoices.find(voice => voice.localService) || availableVoices[0] || null; } // Handle Speech Result function handleSpeechResult(event) { let transcript = ''; let isFinal = false; for (let i = event.resultIndex; i < event.results.length; i++) { transcript += event.results[i][0].transcript; if (event.results[i].isFinal) { isFinal = true; } } // Show interim results sourceText.value = transcript; if (charCount) { charCount.textContent = transcript.length; } if (isFinal) { voiceStatus.textContent = 'โœ“ Transcribed successfully'; setTimeout(() => { voiceStatus.textContent = ''; }, 2000); translateText(); } else { voiceStatus.textContent = '๐ŸŽค Listening... (interim)'; } } // Speak Translation function speakTranslation() { if (!speechSynthesis) { showStatus('Text-to-speech is not supported in this browser', 'error'); return; } const text = translatedText.textContent?.trim(); if (!text || text === 'Translation will appear here...') { showStatus('Nothing to speak', 'error'); return; } const speakText = () => { speechSynthesis.cancel(); speechSynthesis.resume(); const utterance = new SpeechSynthesisUtterance(text.replace(/\s+/g, ' ')); utterance.lang = langMap[targetLang.value] || 'en-US'; utterance.rate = 0.95; utterance.pitch = 1; utterance.volume = 1; if (ttsNotice) { ttsNotice.textContent = remoteTtsLanguages.has(targetLang.value) ? 'Using server TTS for this language. Make sure the browser can reach the running app server.' : 'Using browser speech synthesis for this language.'; } const preferredVoice = getPreferredVoice(utterance.lang); const useRemote = remoteTtsLanguages.has(targetLang.value) && Boolean(API_URL); if (useRemote) { speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice); return; } if (preferredVoice) { utterance.voice = preferredVoice; } utterance.onstart = () => { speakBtn.disabled = true; showStatus('Speaking...', 'success'); }; utterance.onend = () => { speakBtn.disabled = false; showStatus('Done speaking', 'success'); }; utterance.onerror = (e) => { speakBtn.disabled = false; showStatus(`Speech error: ${e.error}`, 'error'); }; speechSynthesis.speak(utterance); }; if (!voicesLoaded && typeof speechSynthesis.getVoices === 'function') { loadVoices(); window.setTimeout(speakText, 250); } else { speakText(); } } async function speakWithRemoteTts(text, targetLang, utterance, preferredVoice) { if (!API_URL) { showStatus('Cannot generate speech without a server URL', 'error'); return; } try { showStatus('Generating speech...', 'loading'); speakBtn.disabled = true; const response = await fetch(`${API_URL}/api/speak`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, targetLang }) }); if (!response.ok) { throw new Error('Remote speech generation failed'); } const data = await response.json(); if (!data.audio) { throw new Error('No audio returned from speech service'); } const audioType = data.format || 'audio/wav'; const audio = new Audio(`data:${audioType};base64,${data.audio}`); audio.onended = () => { speakBtn.disabled = false; showStatus('Done speaking', 'success'); }; audio.onerror = (e) => { console.error('Remote audio playback error', e); if (preferredVoice) { utterance.voice = preferredVoice; } utterance.onstart = () => { speakBtn.disabled = true; showStatus('Speaking...', 'success'); }; utterance.onend = () => { speakBtn.disabled = false; showStatus('Done speaking', 'success'); }; utterance.onerror = (err) => { speakBtn.disabled = false; showStatus(`Speech error: ${err.error}`, 'error'); }; speechSynthesis.speak(utterance); }; await audio.play(); } catch (error) { console.warn('Remote TTS failed, falling back to browser speech:', error); if (ttsNotice) { ttsNotice.textContent = 'Remote TTS failed; using browser speech synthesis fallback.'; } if (preferredVoice) { utterance.voice = preferredVoice; } utterance.onstart = () => { speakBtn.disabled = true; showStatus('Speaking...', 'success'); }; utterance.onend = () => { speakBtn.disabled = false; showStatus('Done speaking', 'success'); }; utterance.onerror = (err) => { speakBtn.disabled = false; showStatus(`Speech error: ${err.error}`, 'error'); }; speechSynthesis.speak(utterance); } } // Copy Translation function copyTranslation() { const text = translatedText.textContent; if (!text || text === 'Translation will appear here...') { showStatus('Nothing to copy', 'error'); return; } navigator.clipboard.writeText(text).then(() => { if (copyFeedback) { copyFeedback.textContent = 'โœ“ Copied!'; setTimeout(() => { copyFeedback.textContent = ''; }, 2000); } showStatus('Copied to clipboard', 'success'); }).catch(err => { showStatus('Failed to copy', 'error'); }); } // Clear Input function clearInput() { sourceText.value = ''; translatedText.textContent = ''; if (charCount) { charCount.textContent = '0'; } if (voiceStatus) { voiceStatus.textContent = ''; } autoSpeakAfterVoice = false; showStatus('Cleared', 'success'); } // Swap Languages function swapLanguages() { const temp = sourceLang.value; sourceLang.value = targetLang.value; targetLang.value = temp; const tempText = sourceText.value; sourceText.value = translatedText.textContent === 'Translation will appear here...' ? '' : translatedText.textContent; translatedText.textContent = tempText || ''; if (charCount) { charCount.textContent = sourceText.value.length; } translateOnChange(); } // Translate on Language Change function translateOnChange() { if (sourceText.value.trim().length > 0) { translateText(); } } // Show Status Message function showStatus(message, type = 'info') { if (!statusMessage) return; statusMessage.textContent = message; statusMessage.className = type; if (type === 'loading') { statusMessage.style.opacity = '0.7'; } else { statusMessage.style.opacity = '1'; } } // Fallback: Simple offline translation (for demo purposes) const translationMap = { 'en-es': { 'hello': 'hola', 'goodbye': 'adiรณs', 'thank you': 'gracias', 'please': 'por favor', 'yes': 'sรญ', 'no': 'no' }, 'en-fr': { 'hello': 'bonjour', 'goodbye': 'au revoir', 'thank you': 'merci', 'please': 's\'il vous plaรฎt', 'yes': 'oui', 'no': 'non' } }; console.log('โœ“ Translator app loaded successfully'); console.log('Features available:'); console.log('- Text translation'); console.log('- Voice input (if supported by browser)'); console.log('- Text-to-speech output'); console.log('- Language swapping'); console.log('- Copy to clipboard'); // Dev log helper: writes to console and to the on-page `#devLogBody` if present. function logDev(message) { const ts = new Date().toISOString(); const full = `${ts} - ${message}`; console.log('DEVLOG', full); if (devLogBody) { const el = document.createElement('div'); el.className = 'dev-log-entry'; el.textContent = full; devLogBody.prepend(el); } }