Improve microphone permission UX: add Dev Log, re-request flow, active Voice Input highlighting, and Puppeteer permission-check script
This commit is contained in:
216
script.js
216
script.js
@@ -9,16 +9,19 @@ const getApiUrl = () => {
|
||||
// Or local IP for testing: 'http://192.168.1.100:3000'
|
||||
return localStorage.getItem('apiUrl') || 'http://localhost:3000';
|
||||
}
|
||||
return ''; // Use relative URL for web
|
||||
|
||||
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);
|
||||
|
||||
// Initialize Speech Recognition API
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const recognition = SpeechRecognition ? new SpeechRecognition() : null;
|
||||
|
||||
function isVoiceInputAllowed() {
|
||||
return window.isSecureContext ||
|
||||
location.protocol === 'https:' ||
|
||||
@@ -48,11 +51,15 @@ 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 recognition and synthesis
|
||||
// Language codes for speech synthesis
|
||||
const langMap = {
|
||||
'en': 'en-US',
|
||||
'es': 'es-ES',
|
||||
@@ -91,12 +98,25 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check voice input support
|
||||
const voiceSupportAvailable = !!(navigator.mediaDevices?.getUserMedia && window.MediaRecorder);
|
||||
if (!voiceSupportAvailable) {
|
||||
console.warn('Voice recording not supported in this browser.');
|
||||
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
|
||||
@@ -111,6 +131,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadVoices();
|
||||
}
|
||||
|
||||
verifyServerReachability();
|
||||
checkMicrophonePermission();
|
||||
showStatus('Ready to translate!', 'success');
|
||||
});
|
||||
|
||||
@@ -124,6 +146,7 @@ function setupEventListeners() {
|
||||
|
||||
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);
|
||||
@@ -131,47 +154,78 @@ function setupEventListeners() {
|
||||
bind(sourceLang, 'change', translateOnChange);
|
||||
bind(targetLang, 'change', translateOnChange);
|
||||
|
||||
if (recognition) {
|
||||
recognition.addEventListener('start', () => {
|
||||
voiceBtn.classList.add('listening');
|
||||
voiceStatus.textContent = '🎤 Listening...';
|
||||
});
|
||||
}
|
||||
|
||||
recognition.addEventListener('result', handleSpeechResult);
|
||||
// Check microphone permission state (if supported) and update UI
|
||||
async function checkMicrophonePermission() {
|
||||
if (!navigator.permissions) return;
|
||||
|
||||
recognition.addEventListener('end', () => {
|
||||
voiceBtn.classList.remove('listening');
|
||||
voiceStatus.textContent = '';
|
||||
});
|
||||
try {
|
||||
const status = await navigator.permissions.query({ name: 'microphone' });
|
||||
|
||||
recognition.addEventListener('error', (e) => {
|
||||
console.error('Speech recognition error:', e.error);
|
||||
let errorMessage = '';
|
||||
|
||||
switch(e.error) {
|
||||
case 'no-speech':
|
||||
errorMessage = 'No speech detected. Please try again.';
|
||||
break;
|
||||
case 'audio-capture':
|
||||
errorMessage = 'Audio capture failed. Check your microphone.';
|
||||
break;
|
||||
case 'not-allowed':
|
||||
errorMessage = 'Microphone permission denied.';
|
||||
break;
|
||||
case 'network':
|
||||
errorMessage = 'Network error during speech recognition.';
|
||||
break;
|
||||
case 'service-not-allowed':
|
||||
errorMessage = 'Speech recognition service not allowed.';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `Speech recognition error: ${e.error}`;
|
||||
if (status.state === 'denied') {
|
||||
if (microphoneHint) {
|
||||
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 http://localhost:3000 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');
|
||||
}
|
||||
|
||||
voiceStatus.textContent = `❌ ${errorMessage}`;
|
||||
voiceBtn.classList.remove('listening');
|
||||
showStatus(errorMessage, 'error');
|
||||
});
|
||||
// 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);
|
||||
showStatus('Cannot reach the app server. Open the app via http://localhost:3000', 'error');
|
||||
logDev(`Server reachability failed: ${error?.message || error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,9 +306,27 @@ async function handleVoiceInput() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVoiceInputAllowed() && !isMobileApp()) {
|
||||
showStatus('Voice input requires a secure context. Please access via https://localhost:3443', '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) {
|
||||
@@ -269,6 +341,11 @@ async function handleVoiceInput() {
|
||||
|
||||
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;
|
||||
|
||||
@@ -295,10 +372,29 @@ async function handleVoiceInput() {
|
||||
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);
|
||||
showStatus('Microphone permission required for voice input', 'error');
|
||||
voiceStatus.textContent = '❌ Microphone access denied';
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,8 +510,14 @@ function speakTranslation() {
|
||||
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) && !!API_URL;
|
||||
const useRemote = remoteTtsLanguages.has(targetLang.value) && Boolean(API_URL);
|
||||
|
||||
if (useRemote) {
|
||||
speakWithRemoteTts(text, targetLang.value, utterance, preferredVoice);
|
||||
@@ -508,6 +610,9 @@ async function speakWithRemoteTts(text, targetLang, utterance, preferredVoice) {
|
||||
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;
|
||||
}
|
||||
@@ -629,3 +734,16 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user