// ui/script.js document.addEventListener('DOMContentLoaded', function () { // --- Global Flags --- let isGenerating = false; let isGenerationCancelled = false; let wavesurfer = null; // Global wavesurfer instance // --- Element Selectors --- const ttsForm = document.getElementById('tts-form'); const textArea = document.getElementById('text'); const charCount = document.getElementById('char-count'); const voiceModeRadios = document.querySelectorAll('input[name="voice_mode"]'); const cloneOptionsDiv = document.getElementById('clone-options'); const cloneReferenceSelect = document.getElementById('clone_reference_select'); const cloneLoadButton = document.getElementById('clone-load-button'); // New ID const cloneFileInput = document.getElementById('clone-file-input'); // New ID const generateBtn = document.getElementById('generate-btn'); const loadingOverlay = document.getElementById('loading-overlay'); const loadingMessage = document.getElementById('loading-message'); const loadingStatus = document.getElementById('loading-status'); // New element for status const loadingCancelBtn = document.getElementById('loading-cancel-btn'); // New ID const notificationArea = document.getElementById('notification-area'); const audioPlayerContainer = document.getElementById('audio-player-container'); const configSaveBtn = document.getElementById('save-config-btn'); const configRestartBtn = document.getElementById('restart-server-btn'); const configStatus = document.getElementById('config-status'); const genDefaultsSaveBtn = document.getElementById('save-gen-defaults-btn'); // New ID const genDefaultsStatus = document.getElementById('gen-defaults-status'); // New ID const themeToggleButton = document.getElementById('theme-toggle-btn'); // New ID const themeIconLight = document.getElementById('theme-icon-light'); // New ID const themeIconDark = document.getElementById('theme-icon-dark'); // New ID const presetsContainer = document.getElementById('presets-container'); // New ID // --- Initial Setup --- // Character counter function updateCharCount() { if (textArea && charCount) { charCount.textContent = textArea.value.length; } } if (textArea) { textArea.addEventListener('input', updateCharCount); updateCharCount(); // Initial count } // Toggle Clone Options Visibility & Required Attribute function toggleCloneOptions() { const selectedMode = document.querySelector('input[name="voice_mode"]:checked')?.value; if (cloneOptionsDiv && cloneReferenceSelect && cloneLoadButton) { if (selectedMode === 'clone') { cloneOptionsDiv.classList.remove('hidden'); cloneReferenceSelect.required = true; cloneLoadButton.classList.remove('hidden'); } else { cloneOptionsDiv.classList.add('hidden'); cloneReferenceSelect.required = false; // cloneReferenceSelect.value = 'none'; // Don't reset if user might switch back cloneLoadButton.classList.add('hidden'); } } } voiceModeRadios.forEach(radio => radio.addEventListener('change', toggleCloneOptions)); toggleCloneOptions(); // Initial check // Update slider value displays dynamically const sliders = [ { id: 'speed_factor', valueId: 'speed_factor_value' }, { id: 'cfg_scale', valueId: 'cfg_scale_value' }, { id: 'temperature', valueId: 'temperature_value' }, { id: 'top_p', valueId: 'top_p_value' }, { id: 'cfg_filter_top_k', valueId: 'cfg_filter_top_k_value' }, ]; sliders.forEach(sliderInfo => { const slider = document.getElementById(sliderInfo.id); const valueDisplay = document.getElementById(sliderInfo.valueId); if (slider && valueDisplay) { // Set initial display from slider's current value (set by template) valueDisplay.textContent = slider.value; // Add event listener to update display on change slider.addEventListener('input', () => valueDisplay.textContent = slider.value); } }); // --- Notifications --- function showNotification(message, type = 'success', duration = 5000) { if (!notificationArea) return; // notificationArea.innerHTML = ''; // Clear previous? Or allow multiple? Let's allow multiple for now. const colors = { success: 'notification-success', error: 'notification-error', warning: 'notification-warning', info: 'notification-info' // Add info style if needed }; const icons = { // SVG icons or classes success: '', error: '', warning: '', info: '' }; const notificationDiv = document.createElement('div'); notificationDiv.className = colors[type] || colors['info']; // Default to info style notificationDiv.innerHTML = `${icons[type] || icons['info']} ${message}`; notificationArea.appendChild(notificationDiv); // Auto-hide after specified duration if (duration > 0) { setTimeout(() => { notificationDiv.style.transition = 'opacity 0.5s ease-out'; notificationDiv.style.opacity = '0'; setTimeout(() => notificationDiv.remove(), 500); }, duration); } return notificationDiv; // Return the element if manual removal is needed } // --- Presets --- function applyPreset(presetData) { console.log("Applying preset:", presetData); if (!presetData) return; // Update text area if (textArea && presetData.text !== undefined) { textArea.value = presetData.text; updateCharCount(); // Update counter } // Update voice mode if (presetData.voice_mode) { const radio = document.querySelector(`input[name="voice_mode"][value="${presetData.voice_mode}"]`); if (radio) { radio.checked = true; toggleCloneOptions(); // Update UI based on new mode } } // Update generation parameters if (presetData.params) { for (const [key, value] of Object.entries(presetData.params)) { const slider = document.getElementById(key); // Assumes slider ID matches param key const valueDisplay = document.getElementById(`${key}_value`); if (slider) { slider.value = value; if (valueDisplay) { valueDisplay.textContent = value; // Update display } } else { console.warn(`Slider element not found for preset parameter: ${key}`); } } } showNotification(`Preset "${presetData.name}" loaded.`, 'info', 3000); } // Add event listeners to preset buttons (assuming they exist) // Presets data should be available globally, e.g., from template `window.appPresets = {{ presets | tojson }};` if (window.appPresets && presetsContainer) { window.appPresets.forEach((preset, index) => { const button = document.getElementById(`preset-btn-${index}`); if (button) { button.addEventListener('click', () => applyPreset(preset)); } }); } else if (presetsContainer) { console.warn("Presets data (window.appPresets) not found, preset buttons will not work."); } // --- Audio Player --- function initializeWaveSurfer(audioUrl) { if (wavesurfer) { wavesurfer.destroy(); } const waveformDiv = document.getElementById('waveform'); const playBtn = document.getElementById('play-btn'); const durationSpan = document.getElementById('audio-duration'); if (!waveformDiv || !playBtn || !durationSpan) { console.error("Audio player elements not found in the container."); // Clear the container if elements are missing after generation if (audioPlayerContainer) audioPlayerContainer.innerHTML = '

Error displaying audio player.

'; return; } // Ensure button text doesn't wrap playBtn.classList.add('whitespace-nowrap', 'flex-shrink-0'); const downloadLink = document.getElementById('download-link'); if (downloadLink) downloadLink.classList.add('whitespace-nowrap', 'flex-shrink-0'); wavesurfer = WaveSurfer.create({ container: waveformDiv, waveColor: document.documentElement.classList.contains('dark') ? '#38bdf8' : '#0ea5e9', // primary-400(dark) / primary-500(light) progressColor: document.documentElement.classList.contains('dark') ? '#0284c7' : '#0369a1', // primary-600(dark) / primary-700(light) cursorColor: document.documentElement.classList.contains('dark') ? '#a855f7' : '#9333ea', // purple-500(dark) / purple-600(light) barWidth: 3, barRadius: 3, cursorWidth: 1, height: 80, barGap: 2, responsive: true, url: audioUrl, mediaControls: false, // Use custom controls normalize: true, }); wavesurfer.on('ready', () => { const duration = wavesurfer.getDuration(); const minutes = Math.floor(duration / 60); const seconds = Math.floor(duration % 60); durationSpan.textContent = `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; playBtn.disabled = false; playBtn.innerHTML = ` Play`; }); wavesurfer.on('play', () => { playBtn.innerHTML = ` Pause`; }); wavesurfer.on('pause', () => { playBtn.innerHTML = ` Play`; }); wavesurfer.on('finish', () => { playBtn.innerHTML = ` Play`; }); playBtn.onclick = () => { wavesurfer.playPause(); }; // Scroll to the player after initialization setTimeout(() => { audioPlayerContainer.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); // Short delay to ensure rendering } // Initialize player if audio URL is present on initial page load // This logic needs to be adapted as the player is now dynamically added // We'll call initializeWaveSurfer if the template renders the player container const initialAudioUrlElement = document.querySelector('[data-initial-audio-url]'); if (initialAudioUrlElement && initialAudioUrlElement.dataset.initialAudioUrl) { console.log("Initializing WaveSurfer for initially loaded audio."); initializeWaveSurfer(initialAudioUrlElement.dataset.initialAudioUrl); } // --- Form Submission & Cancellation --- if (ttsForm) { ttsForm.addEventListener('submit', function (event) { // Client-side validation const text = textArea.value.trim(); const mode = document.querySelector('input[name="voice_mode"]:checked')?.value; const cloneFile = cloneReferenceSelect?.value; if (!text) { showNotification("Please enter some text.", 'error'); event.preventDefault(); return; } if (mode === 'clone' && (!cloneFile || cloneFile === 'none')) { showNotification("Please select a reference file for clone mode.", 'error'); event.preventDefault(); return; } // Handle cancellation of previous request if Generate is clicked again if (isGenerating) { console.log("Generate clicked while previous generation in progress. Setting cancel flag."); showNotification("Cancelling previous request...", 'warning', 2000); isGenerationCancelled = true; // We don't actually stop the backend here (Fake Cancel) // but the result processing will ignore the previous result. } // Reset flags and show loading overlay for the new request isGenerating = true; isGenerationCancelled = false; // Reset cancel flag for the new request if (loadingOverlay && generateBtn && loadingCancelBtn) { loadingMessage.textContent = 'Generating audio...'; // Initial status loadingStatus.textContent = 'Please wait.'; loadingOverlay.classList.remove('hidden'); generateBtn.disabled = true; generateBtn.classList.add('opacity-50', 'cursor-not-allowed'); loadingCancelBtn.disabled = false; // Enable cancel button } // Allow default form submission to proceed // The page will reload with results rendered by the template }); } // Handle Cancel button click if (loadingCancelBtn) { loadingCancelBtn.addEventListener('click', () => { if (isGenerating) { console.log("Cancel button clicked."); isGenerationCancelled = true; isGenerating = false; // Stop considering it "generating" from UI perspective if (loadingOverlay && generateBtn) { loadingOverlay.classList.add('hidden'); // Hide overlay generateBtn.disabled = false; // Re-enable generate button generateBtn.classList.remove('opacity-50', 'cursor-not-allowed'); } showNotification("Generation cancelled by user.", 'info'); // Note: Backend request continues, but result will be ignored on page reload/update } }); } // --- Result Handling (on page load after form submission) --- // This logic runs every time the page loads. We check if specific elements // indicating a successful generation are present. const outputUrlElement = document.getElementById('output-file-url-data'); // Need to add this element in HTML if (outputUrlElement && outputUrlElement.dataset.url) { const outputUrl = outputUrlElement.dataset.url; console.log("Page loaded with generation result:", outputUrl); if (isGenerationCancelled) { console.log("Generation was cancelled, ignoring result."); showNotification("Previous generation was cancelled.", "warning"); // Reset flag after checking isGenerationCancelled = false; } else { console.log("Processing successful generation result."); // The audio player structure should be rendered by the template. // We just need to initialize wavesurfer for it. initializeWaveSurfer(outputUrl); } } // Always reset generating flag on page load, as any active generation is now finished or irrelevant isGenerating = false; if (generateBtn) { // Re-enable button if page reloads for any reason generateBtn.disabled = false; generateBtn.classList.remove('opacity-50', 'cursor-not-allowed'); } // --- Configuration Management --- async function updateConfigStatus(button, statusElement, message, success = true, duration = 5000) { const successClass = 'text-green-500 dark:text-green-400'; const errorClass = 'text-red-500 dark:text-red-400'; const savingClass = 'text-yellow-500 dark:text-yellow-400'; statusElement.textContent = message; statusElement.className = `text-xs ml-2 ${success ? successClass : (message.startsWith('Saving') || message.startsWith('Restarting') ? savingClass : errorClass)}`; statusElement.classList.remove('hidden'); if (button) button.disabled = true; // Disable button while processing // Clear status after duration, re-enable button if (duration > 0) { setTimeout(() => { statusElement.classList.add('hidden'); if (button) button.disabled = false; }, duration); } } // Save Server Configuration if (configSaveBtn) { configSaveBtn.addEventListener('click', async () => { const configData = {}; document.querySelectorAll('#server-config-form input[name]').forEach(input => { // Assume inputs are within a form/div configData[input.name] = input.value; }); updateConfigStatus(configSaveBtn, configStatus, 'Saving...', true, 0); // Indefinite until success/error try { const response = await fetch('/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(configData) }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Failed to save'); updateConfigStatus(configSaveBtn, configStatus, result.message, true); if (configRestartBtn) configRestartBtn.classList.remove('hidden'); // Show restart button } catch (error) { console.error('Error saving server config:', error); updateConfigStatus(configSaveBtn, configStatus, `Error: ${error.message}`, false); } }); } // Restart Server if (configRestartBtn) { configRestartBtn.addEventListener('click', async () => { configRestartBtn.disabled = true; configRestartBtn.innerHTML = ` Restarting...`; updateConfigStatus(configRestartBtn, configStatus, 'Restarting...', true, 0); // Indefinite try { const response = await fetch('/restart_server', { method: 'POST' }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Failed to trigger restart'); updateConfigStatus(configRestartBtn, configStatus, result.message + " Page will attempt reload.", true, 15000); // Show longer // Show main loading overlay during restart check if (loadingOverlay) { loadingMessage.textContent = 'Server restarting...'; loadingStatus.textContent = 'Waiting for server to respond...'; loadingCancelBtn.disabled = true; // Disable cancel during restart loadingOverlay.classList.remove('hidden'); } // Poll for server readiness let attempts = 0; const maxAttempts = 45; // Wait up to 45 seconds function checkServerReady() { attempts++; console.log(`Checking server readiness (Attempt ${attempts}/${maxAttempts})...`); loadingStatus.textContent = `Waiting for server... (${attempts}/${maxAttempts})`; fetch('/health?cache=' + Date.now(), { cache: 'no-store', headers: { 'pragma': 'no-cache' } }) .then(res => { if (res.ok) { console.log("Server is ready. Reloading page."); window.location.reload(true); // Force reload from server } else if (attempts < maxAttempts) { setTimeout(checkServerReady, 1000); // Check again in 1 second } else { throw new Error('Server did not become ready after restart.'); } }) .catch(() => { if (attempts < maxAttempts) { setTimeout(checkServerReady, 1000); // Check again on connection error } else { throw new Error('Server did not respond after restart.'); } }); } setTimeout(checkServerReady, 3000); // Start checking after 3 seconds } catch (error) { console.error('Error restarting server:', error); updateConfigStatus(configRestartBtn, configStatus, `Restart Error: ${error.message}`, false); configRestartBtn.disabled = false; // Re-enable button on error configRestartBtn.innerHTML = ` Restart Server`; if (loadingOverlay) loadingOverlay.classList.add('hidden'); } }); } // Save Generation Defaults if (genDefaultsSaveBtn) { genDefaultsSaveBtn.addEventListener('click', async () => { const genParams = {}; sliders.forEach(s => { const slider = document.getElementById(s.id); if (slider) genParams[s.id] = slider.value; }); updateConfigStatus(genDefaultsSaveBtn, genDefaultsStatus, 'Saving...', true, 0); try { const response = await fetch('/save_generation_defaults', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(genParams) }); const result = await response.json(); if (!response.ok) throw new Error(result.detail || 'Failed to save'); updateConfigStatus(genDefaultsSaveBtn, genDefaultsStatus, result.message, true); } catch (error) { console.error('Error saving generation defaults:', error); updateConfigStatus(genDefaultsSaveBtn, genDefaultsStatus, `Error: ${error.message}`, false); } }); } // --- Reference Audio Upload --- if (cloneLoadButton && cloneFileInput && cloneReferenceSelect) { cloneLoadButton.addEventListener('click', () => { cloneFileInput.click(); // Trigger hidden file input }); cloneFileInput.addEventListener('change', async (event) => { const files = event.target.files; if (!files || files.length === 0) { return; // No files selected } cloneLoadButton.disabled = true; cloneLoadButton.textContent = 'Uploading...'; showNotification(`Uploading ${files.length} file(s)...`, 'info', 0); // Indefinite const formData = new FormData(); for (const file of files) { formData.append('files', file); } try { const response = await fetch('/upload_reference', { method: 'POST', body: formData // Content-Type is set automatically for FormData }); const result = await response.json(); // Clear existing notifications before showing results notificationArea.innerHTML = ''; if (!response.ok) { throw new Error(result.message || `Upload failed with status ${response.status}`); } // Process results if (result.errors && result.errors.length > 0) { result.errors.forEach(err => showNotification(err, 'error')); } if (result.uploaded_files && result.uploaded_files.length > 0) { showNotification(`Successfully uploaded: ${result.uploaded_files.join(', ')}`, 'success'); } else if (!result.errors || result.errors.length === 0) { showNotification("Files processed, but no new files were added (might already exist).", 'info'); } // Update dropdown const currentSelection = cloneReferenceSelect.value; cloneReferenceSelect.innerHTML = ''; // Clear existing options result.all_reference_files.forEach(filename => { const option = document.createElement('option'); option.value = filename; option.textContent = filename; cloneReferenceSelect.appendChild(option); }); // Select the first newly uploaded file, or keep current selection if still valid const firstUploaded = result.uploaded_files ? result.uploaded_files[0] : null; if (firstUploaded) { cloneReferenceSelect.value = firstUploaded; } else if (result.all_reference_files.includes(currentSelection)) { cloneReferenceSelect.value = currentSelection; // Restore previous valid selection } else { cloneReferenceSelect.value = 'none'; // Default if nothing else matches } } catch (error) { console.error('Error uploading reference files:', error); showNotification(`Upload Error: ${error.message}`, 'error'); } finally { cloneLoadButton.disabled = false; cloneLoadButton.textContent = 'Load'; cloneFileInput.value = ''; // Reset file input } }); } // --- Theme Toggle --- function applyTheme(theme) { if (theme === 'light') { document.documentElement.classList.remove('dark'); if (themeIconLight) themeIconLight.classList.remove('hidden'); if (themeIconDark) themeIconDark.classList.add('hidden'); } else { document.documentElement.classList.add('dark'); if (themeIconLight) themeIconLight.classList.add('hidden'); if (themeIconDark) themeIconDark.classList.remove('hidden'); } // Update wavesurfer colors if it exists if (wavesurfer) { wavesurfer.setOptions({ waveColor: theme === 'light' ? '#0ea5e9' : '#38bdf8', progressColor: theme === 'light' ? '#0369a1' : '#0284c7', cursorColor: theme === 'light' ? '#9333ea' : '#a855f7', }); } } if (themeToggleButton) { // Check localStorage on load const savedTheme = localStorage.getItem('theme') || 'dark'; // Default to dark applyTheme(savedTheme); themeToggleButton.addEventListener('click', () => { const isDark = document.documentElement.classList.contains('dark'); const newTheme = isDark ? 'light' : 'dark'; applyTheme(newTheme); localStorage.setItem('theme', newTheme); // Save preference }); } }); // End DOMContentLoaded