// ============================================================ // APP.JS - Core utilities and tab navigation // ============================================================ // --- TAB NAVIGATION --- document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.addEventListener('click', function() { document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); }); document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); }); btn.classList.add('active'); var tabId = btn.getAttribute('data-tab') + '-tab'; document.getElementById(tabId).classList.add('active'); }); }); // --- MODEL SELECTOR --- var modelSelect = document.getElementById('global-model-select'); var costBadge = document.getElementById('model-cost-badge'); var MODEL_COSTS = { 'google/gemini-2.5-flash': '~$0.001/call', 'google/gemini-2.5-flash:thinking': '~$0.002/call', 'deepseek/deepseek-chat-v3-0324': '~$0.001/call', 'deepseek/deepseek-r1:free': 'FREE ✨', 'qwen/qwen3-30b-a3b:free': 'FREE ✨', 'meta-llama/llama-3.1-70b-instruct': '~$0.001/call', 'openai/gpt-4o': '~$0.01/call', 'anthropic/claude-sonnet-4': '~$0.015/call' }; function getSelectedModel() { return modelSelect ? modelSelect.value : 'google/gemini-2.5-flash'; } if (modelSelect) { modelSelect.addEventListener('change', function() { var m = modelSelect.value; if (costBadge) { costBadge.textContent = MODEL_COSTS[m] || '~$0.001/call'; } showToast('Model: ' + m.split('/').pop(), 'info'); }); } // --- LOADING --- function showLoading(text) { document.getElementById('loading-text').textContent = text || 'Processing...'; document.getElementById('loading-overlay').classList.remove('hidden'); } function hideLoading() { document.getElementById('loading-overlay').classList.add('hidden'); } // --- TOAST --- function showToast(message, type) { var container = document.getElementById('toast-container'); var toast = document.createElement('div'); toast.className = 'toast toast-' + (type || 'success'); var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle'; toast.innerHTML = ' ' + message; container.appendChild(toast); setTimeout(function() { toast.remove(); }, 3000); } // --- COPY --- function copyText(elementId) { var el = document.getElementById(elementId); var text = el.innerText || el.textContent; navigator.clipboard.writeText(text).then(function() { showToast('Copied to clipboard!', 'success'); }).catch(function() { // Fallback var range = document.createRange(); range.selectNode(el); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); document.execCommand('copy'); window.getSelection().removeAllRanges(); showToast('Copied!', 'success'); }); } // ============================================================ // SPEAK / STOP READING // ============================================================ var currentlyReadingId = null; var currentAudio = null; function speakText(elementId) { // If already reading THIS element, stop it if (currentlyReadingId === elementId) { stopReading(); return; } // Stop any previous reading first stopReading(); var el = document.getElementById(elementId); var text = (el.innerText || el.textContent).trim(); if (!text) { showToast('Nothing to read', 'error'); return; } // Find the read button that triggered this var readBtn = findReadButton(elementId); // Try ElevenLabs first fetch('/api/text-to-speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.substring(0, 5000) }) }) .then(function(response) { if (!response.ok) throw new Error('ElevenLabs not available'); return response.blob(); }) .then(function(blob) { var url = URL.createObjectURL(blob); currentAudio = new Audio(url); currentlyReadingId = elementId; setReadButtonActive(readBtn, true); currentAudio.onended = function() { stopReading(); }; currentAudio.onerror = function() { stopReading(); // Fall back to browser TTS useBrowserTTS(text, elementId, readBtn); }; currentAudio.play(); showToast('Playing audio — click Stop to end', 'info'); }) .catch(function() { // Fall back to browser speech useBrowserTTS(text, elementId, readBtn); }); } function useBrowserTTS(text, elementId, readBtn) { if (!('speechSynthesis' in window)) { showToast('Text-to-speech not supported', 'error'); return; } window.speechSynthesis.cancel(); var utterance = new SpeechSynthesisUtterance(text); utterance.rate = 0.9; utterance.pitch = 1.0; utterance.onend = function() { stopReading(); }; utterance.onerror = function() { stopReading(); }; currentlyReadingId = elementId; setReadButtonActive(readBtn, true); window.speechSynthesis.speak(utterance); showToast('Reading aloud — click Stop to end', 'info'); } function stopReading() { // Stop browser TTS if ('speechSynthesis' in window) { window.speechSynthesis.cancel(); } // Stop ElevenLabs audio if (currentAudio) { currentAudio.pause(); currentAudio.currentTime = 0; if (currentAudio.src && currentAudio.src.startsWith('blob:')) { URL.revokeObjectURL(currentAudio.src); } currentAudio = null; } // Reset button state if (currentlyReadingId) { var readBtn = findReadButton(currentlyReadingId); setReadButtonActive(readBtn, false); currentlyReadingId = null; } } function findReadButton(elementId) { // Walk up from the output text to find the read button in the same card var el = document.getElementById(elementId); if (!el) return null; var card = el.closest('.output-card') || el.closest('.card'); if (!card) return null; // Find the button that calls speakText with this elementId var buttons = card.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { var onclick = buttons[i].getAttribute('onclick') || ''; if (onclick.indexOf('speakText') !== -1 && onclick.indexOf(elementId) !== -1) { return buttons[i]; } } return null; } function setReadButtonActive(btn, isActive) { if (!btn) return; if (isActive) { btn.classList.add('btn-reading'); btn.innerHTML = ' Stop'; } else { btn.classList.remove('btn-reading'); btn.innerHTML = ' Read'; } } // --- TIMER --- function createTimer(displayEl) { var secs = 0; var interval = null; return { start: function() { secs = 0; this.update(); var self = this; interval = setInterval(function() { secs++; self.update(); }, 1000); }, stop: function() { if (interval) { clearInterval(interval); interval = null; } return secs; }, update: function() { var m = String(Math.floor(secs / 60)).padStart(2, '0'); var s = String(secs % 60).padStart(2, '0'); displayEl.textContent = m + ':' + s; } }; } // --- AUDIO RECORDER --- function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; } AudioRecorder.prototype.start = function() { var self = this; self.chunks = []; return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } }).then(function(stream) { self.stream = stream; var mimeType = 'audio/webm'; if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { mimeType = 'audio/webm;codecs=opus'; } self.mediaRecorder = new MediaRecorder(stream, { mimeType: mimeType }); self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); }; self.mediaRecorder.start(1000); }); }; AudioRecorder.prototype.stop = function() { var self = this; return new Promise(function(resolve) { if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; } self.mediaRecorder.onstop = function() { var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType }); if (self.stream) { self.stream.getTracks().forEach(function(t) { t.stop(); }); } resolve(blob); }; self.mediaRecorder.stop(); }); }; console.log('✅ App.js loaded');