diff --git "a/index.html" "b/index.html" --- "a/index.html" +++ "b/index.html" @@ -3,7 +3,7 @@ - Google TTS Document Reader (BYOK) + Enhanced Google TTS Document Reader (BYOK)
-

Google TTS Document Reader

+

Enhanced Google TTS Document Reader

Natural HD English voices with BYOK (Bring Your Own Key)

-
@@ -160,19 +367,22 @@
-
-
-

- Your API key is stored locally and never sent to our servers. +

+ Your API key is stored locally and never sent to our servers.

Need help getting an API key? (Click to expand) @@ -191,7 +401,7 @@
@@ -201,16 +411,21 @@
-
- + - -
+
- +

Drag & Drop File

Supports: TXT, PDF, DOC/DOCX, RTF, MD, JSON, HTML, XML, CSV

@@ -224,46 +439,67 @@

Document Content

-
-
0 characters
+
+ +
0 characters
-
+

Your document content will appear here...

- +
-

Voice & Settings

-
+

Voice Configuration

+
+
+ + Loading... +
+
- +
-
+ +
+ + +

Standard voices ($4/million chars) are selected by default

+
+ -
- - -
-
- - -
- -
- - -
- -
- - -
-
- - -
- - -
- -
-
-
- Selected Voice: - None + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
-
- + - - -
- + + +
-
- 0:00 - - - +
+ 0:00 0:00
-
+
-
+
-
+
-
+
+ + + + + + + + + @@ -383,6 +669,7 @@ let availableVoices = []; let isPlaying = false; let isPaused = false; + let isSynthesizing = false; let audioContext = null; let audioSource = null; let audioBuffer = null; @@ -391,7 +678,8 @@ let currentAudioBlob = null; let currentReadingPosition = 0; let estimatedDuration = 0; - let synthesisProgressInterval = null; + let audioLibrary = JSON.parse(localStorage.getItem('audioLibrary') || '[]'); + let bookmarks = JSON.parse(localStorage.getItem('documentBookmarks') || '[]'); const MAX_CHUNK_SIZE = 5000; const PRICING = { @@ -400,24 +688,19 @@ neural2: 16.00, studio: 160.00 }; - - // Default male voices preference (Standard model only) - const defaultStandardMaleVoices = [ - 'en-US-Standard-A', // Male voice - 'en-US-Standard-B', // Male voice - 'en-US-Standard-D', // Male voice - 'en-US-Standard-I', // Male voice - 'en-US-Standard-J' // Male voice - ]; - - // Global error handler + + // Global error handlers window.addEventListener('error', (e) => { console.error('Global error caught:', e.error); + console.error('Error stack:', e.error?.stack); + showToast('An unexpected error occurred. Please try again.', 'error'); + hideLoadingOverlay(); }); - // Global unhandled promise rejection handler window.addEventListener('unhandledrejection', (e) => { console.error('Unhandled promise rejection:', e.reason); + showToast('Promise rejection: ' + (e.reason?.message || e.reason), 'error'); + hideLoadingOverlay(); e.preventDefault(); }); @@ -426,33 +709,91 @@ const saveKeyBtn = document.getElementById('saveKeyBtn'); const uploadBtn = document.getElementById('uploadBtn'); const pasteBtn = document.getElementById('pasteBtn'); + const clearBtn = document.getElementById('clearBtn'); const dropzone = document.getElementById('dropzone'); const fileInput = document.getElementById('fileInput'); const documentContent = document.getElementById('documentContent'); const charCount = document.getElementById('charCount'); + const bookmarkBtn = document.getElementById('bookmarkBtn'); const voiceSelect = document.getElementById('voiceSelect'); const refreshVoicesBtn = document.getElementById('refreshVoicesBtn'); const languageSelect = document.getElementById('languageSelect'); const rateSelect = document.getElementById('rateSelect'); const pitchSelect = document.getElementById('pitchSelect'); const modelSelect = document.getElementById('modelSelect'); + const synthesizeBtn = document.getElementById('synthesizeBtn'); const playBtn = document.getElementById('playBtn'); - const pauseBtn = document.getElementById('pauseBtn'); const stopBtn = document.getElementById('stopBtn'); const downloadBtn = document.getElementById('downloadBtn'); - const selectedVoiceName = document.getElementById('selectedVoiceName'); + const libraryBtn = document.getElementById('libraryBtn'); const currentTime = document.getElementById('currentTime'); const totalTime = document.getElementById('totalTime'); const progressContainer = document.getElementById('progressContainer'); const progressBar = document.getElementById('progressBar'); const readingProgress = document.getElementById('readingProgress'); const currentPositionMarker = document.getElementById('currentPositionMarker'); + const voiceStatus = document.getElementById('voiceStatus'); + const statusIndicator = document.getElementById('statusIndicator'); + const statusIcon = document.getElementById('statusIcon'); + const statusText = document.getElementById('statusText'); + const loadingOverlay = document.getElementById('loadingOverlay'); + const loadingTitle = document.getElementById('loadingTitle'); + const loadingMessage = document.getElementById('loadingMessage'); const synthesisProgress = document.getElementById('synthesisProgress'); - const progressPercentage = document.getElementById('progressPercentage'); + const synthesisProgressBar = document.getElementById('synthesisProgressBar'); + const synthesisPercentage = document.getElementById('synthesisPercentage'); + + // Library modal elements + const libraryModal = document.getElementById('libraryModal'); + const closeLibraryModal = document.getElementById('closeLibraryModal'); + const saveToLibraryBtn = document.getElementById('saveToLibraryBtn'); + const clearLibraryBtn = document.getElementById('clearLibraryBtn'); + const libraryContent = document.getElementById('libraryContent'); + const libraryCount = document.getElementById('libraryCount'); // Dark mode elements const darkModeToggle = document.getElementById('darkModeToggle'); + // Utility function for file size formatting + function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + // Loading overlay functions + function showLoadingOverlay(title = 'Loading...', message = 'Please wait') { + loadingTitle.textContent = title; + loadingMessage.textContent = message; + loadingOverlay.classList.remove('hidden'); + loadingOverlay.setAttribute('aria-busy', 'true'); + } + + function hideLoadingOverlay() { + loadingOverlay.classList.add('hidden'); + loadingOverlay.setAttribute('aria-busy', 'false'); + } + + // Status indicator functions + function showStatus(text, icon = 'fa-info-circle', type = 'info') { + statusText.textContent = text; + statusIcon.className = `fas ${icon} mr-2`; + statusIndicator.classList.remove('hidden'); + + // Auto-hide after 5 seconds for non-persistent status + if (type !== 'persistent') { + setTimeout(() => { + statusIndicator.classList.add('hidden'); + }, 5000); + } + } + + function hideStatus() { + statusIndicator.classList.add('hidden'); + } + // Initialize function function init() { // Load saved API key @@ -489,28 +830,173 @@ // Initialize progress indicator initProgressIndicator(); - // Initialize download feature - initDownloadFeature(); + // Initialize library + updateLibraryView(); - // Event listeners to update cost estimates + // Event listeners modelSelect.addEventListener('change', (e) => { updateCostEstimator(); - filterVoicesByModel(e.target.value); + autoSelectVoiceForModel(e.target.value); }); - voiceSelect.addEventListener('change', (e) => { - const selectedVoiceName = e.target.value; - if (selectedVoiceName) { - selectedVoice = availableVoices.find(voice => voice.name === selectedVoiceName); - updateSelectedVoice(); - updateCostEstimator(); + rateSelect.addEventListener('change', updateCostEstimator); + pitchSelect.addEventListener('change', updateCostEstimator); + voiceSelect.addEventListener('change', onVoiceSelectionChange); + + // Keyboard navigation for dropzone + dropzone.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInput.click(); } }); - rateSelect.addEventListener('change', updateCostEstimator); - pitchSelect.addEventListener('change', updateCostEstimator); + // Initialize cost breakdown + document.getElementById('showCostBreakdown').addEventListener('click', showCostBreakdownModal); - document.getElementById('showCostBreakdown')?.addEventListener('click', showCostBreakdownModal); + // Set default status + updateVoiceStatus('loading', 'Loading voices...'); + showStatus('Ready to load documents', 'fa-file-alt'); + } + + // Dark Mode Implementation + function initDarkMode() { + const savedTheme = localStorage.getItem('theme'); + const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + + if (savedTheme) { + document.documentElement.setAttribute('data-theme', savedTheme); + updateDarkModeIcon(savedTheme === 'dark'); + } else if (systemPrefersDark) { + document.documentElement.setAttribute('data-theme', 'dark'); + updateDarkModeIcon(true); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.setAttribute('data-theme', 'light'); + updateDarkModeIcon(false); + localStorage.setItem('theme', 'light'); + } + + darkModeToggle.addEventListener('click', toggleDarkMode); + } + + function toggleDarkMode() { + const currentTheme = document.documentElement.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + document.documentElement.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + updateDarkModeIcon(newTheme === 'dark'); + showToast(`Switched to ${newTheme} mode`, 'success', 1500); + } + + function updateDarkModeIcon(isDark) { + const icon = darkModeToggle.querySelector('i'); + icon.className = isDark ? 'fas fa-sun text-xl' : 'fas fa-moon text-xl'; + darkModeToggle.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode'); + } + + // Progress indicator initialization + function initProgressIndicator() { + progressContainer.addEventListener('click', (e) => { + if (!audioSource || !audioBuffer) return; + + const rect = progressContainer.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const percentage = clickX / rect.width; + const newTime = percentage * audioBuffer.duration; + + seekToTime(newTime); + }); + } + + function seekToTime(time) { + if (!audioSource || !audioBuffer) return; + + audioSource.stop(); + + audioSource = audioContext.createBufferSource(); + audioSource.buffer = audioBuffer; + audioSource.connect(audioContext.destination); + + startTime = audioContext.currentTime - time; + audioSource.start(0, time); + + audioSource.onended = () => { + if (isPlaying && !isPaused) { + stopPlayback(); + } + }; + + showToast(`Seeked to ${formatTime(Math.floor(time))}`, 'info', 1000); + } + + // Voice status management + function updateVoiceStatus(status, message) { + const statusEl = voiceStatus; + const iconEl = statusEl.querySelector('i'); + const textEl = statusEl.querySelector('span'); + + statusEl.className = `voice-status ${status}`; + textEl.textContent = message; + + switch (status) { + case 'available': + iconEl.className = 'fas fa-check'; + break; + case 'loading': + iconEl.className = 'fas fa-spinner fa-spin'; + break; + case 'error': + iconEl.className = 'fas fa-exclamation-triangle'; + break; + } + } + + // Voice selection handling + function onVoiceSelectionChange() { + const selectedVoiceName = voiceSelect.value; + if (selectedVoiceName && availableVoices.length > 0) { + selectedVoice = availableVoices.find(voice => voice.name === selectedVoiceName); + if (selectedVoice) { + updateCostEstimator(); + showToast(`Voice changed to ${selectedVoice.name}`, 'success', 1500); + } + } else { + selectedVoice = null; + } + } + + // Auto-select voice based on model + function autoSelectVoiceForModel(model) { + if (!availableVoices.length) return; + + let targetVoices = []; + + switch (model) { + case 'standard': + targetVoices = availableVoices.filter(voice => + !voice.name.includes('Wavenet') && + !voice.name.includes('Neural2') && + !voice.name.includes('Studio') + ); + break; + case 'wavenet': + targetVoices = availableVoices.filter(voice => voice.name.includes('Wavenet')); + break; + case 'neural2': + targetVoices = availableVoices.filter(voice => voice.name.includes('Neural2')); + break; + case 'studio': + targetVoices = availableVoices.filter(voice => voice.name.includes('Studio')); + break; + } + + if (targetVoices.length > 0) { + selectedVoice = targetVoices[0]; + voiceSelect.value = selectedVoice.name; + updateCostEstimator(); + } } // Voice type and pricing functions @@ -577,73 +1063,20 @@ } } - // Filter voices by selected model - function filterVoicesByModel(model) { - const filteredVoices = availableVoices.filter(voice => { - const voiceType = getVoiceTypeAndPricing(voice.name); - return voiceType.type === model; - }); - - populateVoiceDropdown(filteredVoices); - selectDefaultMaleVoice(filteredVoices, model); - } - - // Find and select default male voice - function selectDefaultMaleVoice(voices, model) { - if (!voices.length) return; - - // For standard model, use specific preferred voices - if (model === 'standard') { - for (const preferredVoice of defaultStandardMaleVoices) { - const voice = voices.find(v => v.name === preferredVoice); - if (voice) { - selectVoice(voice); - return voice; - } - } - } - - // Fallback to first male voice of the selected model - const maleVoice = voices.find(v => v.ssmlGender === 'MALE'); - if (maleVoice) { - selectVoice(maleVoice); - return maleVoice; - } - - // Final fallback to any voice of the selected model - if (voices.length > 0) { - selectVoice(voices[0]); - return voices[0]; - } - - return null; - } - - function selectVoice(voice) { - selectedVoice = voice; - voiceSelect.value = voice.name; - updateSelectedVoice(); - updateCostEstimator(); - } - - function updateSelectedVoice() { - if (selectedVoice) { - selectedVoiceName.textContent = selectedVoice.name; - } else { - selectedVoiceName.textContent = 'None'; - } - } - // Cost breakdown modal function showCostBreakdownModal() { const modal = document.createElement('div'); - modal.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'; + modal.className = 'modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-labelledby', 'pricingModalTitle'); + modal.innerHTML = ` -
+