// content.js — Arabic Voice Input for Claude.ai // Injects a microphone button that uses Web Speech API for Arabic speech recognition (function () { 'use strict'; // Prevent double injection if (window.__claudeArabicVoiceLoaded) return; window.__claudeArabicVoiceLoaded = true; // ─── State ─────────────────────────────────────────────────────────────── let recognition = null; let isListening = false; let micButton = null; let statusIndicator = null; let interimText = ''; let finalText = ''; let autoSendTimer = null; let silenceTimer = null; let lastSpeechTime = 0; let isGeminiProcessing = false; const SILENCE_TIMEOUT = 2000; // 2 seconds of silence = auto-stop const MAX_RECORDING_TIME = 30000; // 30 seconds max recording // ─── Settings ──────────────────────────────────────────────────────────── let settings = { language: 'ar-SA', // Default: Arabic (Saudi Arabia) autoSend: false, // Auto-send after stopping useGemini: false, // Use Gemini AI to refine text geminiApiKey: '', geminiModel: 'gemini-flash-lite-latest' }; // ─── Load Settings ─────────────────────────────────────────────────────── async function loadSettings() { return new Promise((resolve) => { chrome.storage.sync.get( ['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'], (data) => { if (data.language) settings.language = data.language; if (data.autoSend !== undefined) settings.autoSend = data.autoSend; if (data.useGemini !== undefined) settings.useGemini = data.useGemini; if (data.geminiApiKey) settings.geminiApiKey = data.geminiApiKey; if (data.geminiModel) settings.geminiModel = data.geminiModel; resolve(); } ); }); } // ─── Speech Recognition Setup ──────────────────────────────────────────── function initSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { console.warn('[ClaudeVoice] Speech recognition not supported in this browser'); return null; } const recog = new SpeechRecognition(); recog.continuous = true; recog.interimResults = true; recog.lang = settings.language; recog.maxAlternatives = 1; recog.onresult = (event) => { lastSpeechTime = Date.now(); interimText = ''; finalText = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; if (result.isFinal) { finalText += result[0].transcript + ' '; } else { interimText += result[0].transcript; } } updateInputField(); // Reset silence timer on new speech clearTimeout(silenceTimer); silenceTimer = setTimeout(() => { if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { stopListening(); } }, SILENCE_TIMEOUT); }; recog.onerror = (event) => { console.error('[ClaudeVoice] Recognition error:', event.error); if (event.error === 'no-speech') { // No speech detected, restart if still listening if (isListening) { try { recog.stop(); } catch (e) { } setTimeout(() => { if (isListening) { try { recog.start(); } catch (e) { } } }, 300); } return; } stopListening(); showStatus('error', '❌ خطأ: ' + getArabicError(event.error)); }; recog.onend = () => { // If we're still supposed to be listening, restart if (isListening) { try { recog.start(); } catch (e) { console.warn('[ClaudeVoice] Restart failed:', e); } } }; return recog; } function getArabicError(error) { const errors = { 'no-speech': 'لم يتم اكتشاف كلام', 'aborted': 'تم الإلغاء', 'audio-capture': 'الميكروفون غير متاح', 'network': 'خطأ في الشبكة', 'not-allowed': 'الرجاء السماح باستخدام الميكروفون', 'service-not-allowed': 'الخدمة غير متاحة', 'bad-grammar': 'خطأ في القواعد' }; return errors[error] || error; } // ─── Start / Stop Listening ───────────────────────────────────────────── async function startListening() { await loadSettings(); if (!recognition) { recognition = initSpeechRecognition(); } if (!recognition) { showStatus('error', '❌ المتصفح لا يدعم التعرف على الصوت'); return; } // Update language in case it changed recognition.lang = settings.language; try { recognition.start(); isListening = true; lastSpeechTime = Date.now(); updateMicButton(true); showStatus('listening', '🎤 جارٍ الاستماع...'); // Auto-stop after max time clearTimeout(autoSendTimer); autoSendTimer = setTimeout(() => { if (isListening) stopListening(); }, MAX_RECORDING_TIME); // Silence detection clearTimeout(silenceTimer); silenceTimer = setTimeout(() => { if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { stopListening(); } }, SILENCE_TIMEOUT); } catch (e) { console.error('[ClaudeVoice] Start failed:', e); showStatus('error', '❌ فشل بدء التسجيل'); } } async function stopListening() { if (!recognition) return; isListening = false; clearTimeout(silenceTimer); clearTimeout(autoSendTimer); try { recognition.stop(); } catch (e) { /* ignore */ } updateMicButton(false); // If we have final text, process it const text = finalText.trim() || interimText.trim(); if (text) { showStatus('processing', '⏳ جارٍ المعالجة...'); if (settings.useGemini && settings.geminiApiKey) { await processWithGemini(text); } else { // Just insert the text directly insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج'); setTimeout(() => hideStatus(), 2000); } } else { showStatus('idle', '🎤 اضغط للتحدث'); setTimeout(() => hideStatus(), 1500); } } // ─── Update Claude Input ───────────────────────────────────────────────── function updateInputField() { const fullText = (finalText + interimText).trim(); if (!fullText) return; // Find Claude's input area const inputArea = findClaudeInput(); if (!inputArea) return; // For interim results, we update a placeholder // For final results, we insert the text if (interimText) { // Show interim in a floating preview showInterimPreview(interimText); } else { hideInterimPreview(); } } function findClaudeInput() { // Claude.ai uses a contenteditable div or textarea // Try multiple selectors as Claude's UI may change const selectors = [ '[contenteditable="true"][role="textbox"]', '[contenteditable="true"].ProseMirror', 'div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'textarea[placeholder*="Ask"]', 'textarea[placeholder*="مراسلة"]', 'textarea[placeholder*="اسأل"]', '.ProseMirror[contenteditable="true"]', 'div[role="textbox"][contenteditable="true"]' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } return null; } function findSendButton() { const selectors = [ 'button[aria-label*="Send"]', 'button[aria-label*="إرسال"]', 'button[data-testid*="send"]', 'button[class*="send"]', 'button[class*="Send"]', 'form button[type="submit"]', // Claude's specific send button 'button:has(svg[class*="send"])', 'button:has(svg[data-icon*="arrow"])' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } return null; } function insertTextIntoClaude(text) { const inputArea = findClaudeInput(); if (!inputArea) { showStatus('error', '❌ لم يتم العثور على حقل الإدخال'); return false; } // Check if it's a contenteditable div (ProseMirror) or textarea if (inputArea.isContentEditable || inputArea.tagName === 'DIV') { // For contenteditable (Claude's ProseMirror editor) // Insert text at cursor position or append const selection = window.getSelection(); const range = document.createRange(); // Focus the input area inputArea.focus(); // Try to place cursor at end if (inputArea.lastChild) { range.setStartAfter(inputArea.lastChild); range.setEndAfter(inputArea.lastChild); } else { range.selectNodeContents(inputArea); range.collapse(false); } selection.removeAllRanges(); selection.addRange(range); // Insert text document.execCommand('insertText', false, text); // Dispatch input event for React/ProseMirror to detect inputArea.dispatchEvent(new Event('input', { bubbles: true })); inputArea.dispatchEvent(new Event('change', { bubbles: true })); } else if (inputArea.tagName === 'TEXTAREA' || inputArea.tagName === 'INPUT') { // For textarea/input const start = inputArea.selectionStart || inputArea.value.length; const end = inputArea.selectionEnd || inputArea.value.length; inputArea.value = inputArea.value.substring(0, start) + text + inputArea.value.substring(end); inputArea.selectionStart = inputArea.selectionEnd = start + text.length; inputArea.dispatchEvent(new Event('input', { bubbles: true })); inputArea.dispatchEvent(new Event('change', { bubbles: true })); } // Auto-send if enabled if (settings.autoSend) { setTimeout(() => { const sendBtn = findSendButton(); if (sendBtn && !sendBtn.disabled) { sendBtn.click(); } }, 500); } return true; } // ─── Interim Preview ───────────────────────────────────────────────────── let previewEl = null; function showInterimPreview(text) { if (!previewEl) { previewEl = document.createElement('div'); previewEl.id = 'claude-voice-interim'; previewEl.style.cssText = ` position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); color: #fff; padding: 12px 20px; border-radius: 12px; font-size: 16px; max-width: 600px; width: 90%; text-align: center; z-index: 999999; direction: rtl; font-family: 'Segoe UI', Tahoma, sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); pointer-events: none; `; document.body.appendChild(previewEl); } previewEl.textContent = '🎤 ' + text + ' ▊'; previewEl.style.display = 'block'; } function hideInterimPreview() { if (previewEl) { previewEl.style.display = 'none'; } } // ─── Gemini AI Processing ──────────────────────────────────────────────── async function processWithGemini(text) { isGeminiProcessing = true; try { const response = await chrome.runtime.sendMessage({ type: 'GEMINI_PROCESS_VOICE', payload: { apiKey: settings.geminiApiKey, model: settings.geminiModel, text: text, language: settings.language } }); if (response && response.success) { const processedText = response.data.text; insertTextIntoClaude(processedText); showStatus('done', '✅ تمت المعالجة بواسطة Gemini'); } else { // Fallback: insert original text insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج (بدون معالجة)'); } } catch (e) { console.error('[ClaudeVoice] Gemini error:', e); insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج (بدون معالجة)'); } isGeminiProcessing = false; setTimeout(() => hideStatus(), 2000); } // ─── UI: Mic Button ────────────────────────────────────────────────────── function createMicButton() { if (micButton) return; micButton = document.createElement('button'); micButton.id = 'claude-voice-mic-btn'; micButton.innerHTML = '🎤'; micButton.title = 'إملاء صوتي عربي - اضغط للتحدث'; micButton.setAttribute('aria-label', 'إملاء صوتي عربي'); micButton.addEventListener('click', () => { if (isListening) { stopListening(); } else { startListening(); } }); // Status indicator statusIndicator = document.createElement('div'); statusIndicator.id = 'claude-voice-status'; statusIndicator.textContent = '🎤 اضغط للتحدث'; statusIndicator.style.display = 'none'; // Container const container = document.createElement('div'); container.id = 'claude-voice-container'; container.appendChild(micButton); container.appendChild(statusIndicator); document.body.appendChild(container); // Position the button near Claude's input area positionMicButton(); } function positionMicButton() { // Try to find the input area and position near it const observer = new MutationObserver(() => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; micButton.style.right = '20px'; } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); // Initial positioning setTimeout(() => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }, 2000); } function updateMicButton(listening) { if (!micButton) return; if (listening) { micButton.classList.add('listening'); micButton.innerHTML = '🔴'; micButton.title = 'إيقاف التسجيل'; } else { micButton.classList.remove('listening'); micButton.innerHTML = '🎤'; micButton.title = 'إملاء صوتي عربي - اضغط للتحدث'; } } function showStatus(type, message) { if (!statusIndicator) return; statusIndicator.textContent = message; statusIndicator.className = 'claude-voice-status-' + type; statusIndicator.style.display = 'block'; } function hideStatus() { if (!statusIndicator) return; statusIndicator.style.display = 'none'; } // ─── Inject Styles ─────────────────────────────────────────────────────── function injectStyles() { // Styles are loaded from styles.css via manifest // But we also inject some critical inline styles const style = document.createElement('style'); style.textContent = ` #claude-voice-container { position: fixed; z-index: 999999; display: flex; flex-direction: column; align-items: center; gap: 8px; bottom: 120px; right: 20px; } #claude-voice-mic-btn { width: 56px; height: 56px; border-radius: 50%; border: none; background: linear-gradient(135deg, #6c63ff, #4834d4); color: white; font-size: 24px; cursor: pointer; box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4); transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; position: relative; } #claude-voice-mic-btn:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(108, 99, 255, 0.6); } #claude-voice-mic-btn.listening { background: linear-gradient(135deg, #ff4444, #cc0000); animation: claude-voice-pulse 1.5s infinite; box-shadow: 0 4px 20px rgba(255, 68, 68, 0.6); } @keyframes claude-voice-pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.6); } 50% { box-shadow: 0 0 0 15px rgba(255, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0); } } #claude-voice-status { background: rgba(0, 0, 0, 0.8); color: white; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-family: 'Segoe UI', Tahoma, sans-serif; white-space: nowrap; direction: rtl; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); pointer-events: none; } .claude-voice-status-listening { background: rgba(255, 68, 68, 0.9) !important; } .claude-voice-status-processing { background: rgba(255, 165, 0, 0.9) !important; } .claude-voice-status-done { background: rgba(0, 200, 83, 0.9) !important; } .claude-voice-status-error { background: rgba(255, 0, 0, 0.9) !important; } .claude-voice-status-idle { background: rgba(108, 99, 255, 0.9) !important; } `; document.head.appendChild(style); } // ─── Message Listener (for popup communication) ───────────────────────── chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'GET_STATUS') { sendResponse({ isListening }); return true; } if (message.type === 'SETTINGS_UPDATED') { if (message.payload.language) settings.language = message.payload.language; if (message.payload.autoSend !== undefined) settings.autoSend = message.payload.autoSend; if (message.payload.useGemini !== undefined) settings.useGemini = message.payload.useGemini; if (message.payload.geminiApiKey) settings.geminiApiKey = message.payload.geminiApiKey; if (message.payload.geminiModel) settings.geminiModel = message.payload.geminiModel; console.log('[ClaudeVoice] Settings updated:', settings); sendResponse({ success: true }); return true; } }); // ─── Initialize ────────────────────────────────────────────────────────── async function init() { await loadSettings(); injectStyles(); createMicButton(); // Re-position on scroll and resize window.addEventListener('scroll', () => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }); window.addEventListener('resize', () => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }); console.log('[ClaudeVoice] ✅ Arabic voice input extension loaded'); console.log('[ClaudeVoice] Language:', settings.language); console.log('[ClaudeVoice] Gemini:', settings.useGemini ? 'Enabled' : 'Disabled'); } // Wait for page to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();