753 lines
25 KiB
JavaScript
753 lines
25 KiB
JavaScript
// 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, '<br>');
|
|
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:<br>• Click the lock icon in the address bar → Site settings → Microphone → Allow<br>• 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, '<br>');
|
|
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);
|
|
}
|
|
}
|