// GrabZilla 2.1 - Application Entry Point // Modular architecture with clear separation of concerns class GrabZillaApp { constructor() { this.state = null; this.eventBus = null; this.initialized = false; this.modules = new Map(); } // Initialize the application async init() { try { console.log('๐Ÿš€ Initializing GrabZilla 2.1...'); // Initialize event bus this.eventBus = window.eventBus; if (!this.eventBus) { throw new Error('EventBus not available'); } // Initialize application state this.state = new window.AppState(); if (!this.state) { throw new Error('AppState not available'); } // Expose state globally for Video model to access current defaults window.appState = this.state; // Set up error handling this.setupErrorHandling(); // Initialize UI components await this.initializeUI(); // Set up event listeners this.setupEventListeners(); // Load saved state if available await this.loadState(); // Ensure save directory exists await this.ensureSaveDirectoryExists(); // Check binary status and validate await this.checkAndValidateBinaries(); // Initialize keyboard navigation this.initializeKeyboardNavigation(); this.initialized = true; console.log('โœ… GrabZilla 2.1 initialized successfully'); // Notify that the app is ready this.eventBus.emit('app:ready', { app: this }); } catch (error) { console.error('โŒ Failed to initialize GrabZilla:', error); this.handleInitializationError(error); } } // Set up global error handling setupErrorHandling() { // Handle unhandled errors window.addEventListener('error', (event) => { console.error('Global error:', event.error); this.eventBus.emit('app:error', { type: 'global', error: event.error, filename: event.filename, lineno: event.lineno }); }); // Handle unhandled promise rejections window.addEventListener('unhandledrejection', (event) => { console.error('Unhandled promise rejection:', event.reason); this.eventBus.emit('app:error', { type: 'promise', error: event.reason }); }); // Listen for application errors this.eventBus.on('app:error', (errorData) => { // Handle errors appropriately this.displayError(errorData); }); } // Initialize UI components async initializeUI() { // Update save path display this.updateSavePathDisplay(); // Initialize dropdown values this.initializeDropdowns(); // Set up video list this.initializeVideoList(); // Set up status display this.updateStatusMessage('Ready to download videos'); } // Set up main event listeners setupEventListeners() { // State change listeners this.state.on('videoAdded', (data) => this.onVideoAdded(data)); this.state.on('videoRemoved', (data) => this.onVideoRemoved(data)); this.state.on('videoUpdated', (data) => this.onVideoUpdated(data)); this.state.on('videosReordered', (data) => this.onVideosReordered(data)); this.state.on('videosCleared', (data) => this.onVideosCleared(data)); this.state.on('configUpdated', (data) => this.onConfigUpdated(data)); // UI event listeners this.setupButtonEventListeners(); this.setupInputEventListeners(); this.setupVideoListEventListeners(); } // Set up button event listeners setupButtonEventListeners() { // Add Video button const addVideoBtn = document.getElementById('addVideoBtn'); if (addVideoBtn) { addVideoBtn.addEventListener('click', () => this.handleAddVideo()); } // Import URLs button const importUrlsBtn = document.getElementById('importUrlsBtn'); if (importUrlsBtn) { importUrlsBtn.addEventListener('click', () => this.handleImportUrls()); } // Save Path button const savePathBtn = document.getElementById('savePathBtn'); if (savePathBtn) { savePathBtn.addEventListener('click', () => this.handleSelectSavePath()); } // Cookie File button const cookieFileBtn = document.getElementById('cookieFileBtn'); if (cookieFileBtn) { cookieFileBtn.addEventListener('click', () => this.handleSelectCookieFile()); } // Control panel buttons const clearListBtn = document.getElementById('clearListBtn'); if (clearListBtn) { clearListBtn.addEventListener('click', () => this.handleClearList()); } const downloadVideosBtn = document.getElementById('downloadVideosBtn'); if (downloadVideosBtn) { downloadVideosBtn.addEventListener('click', () => this.handleDownloadVideos()); } const cancelDownloadsBtn = document.getElementById('cancelDownloadsBtn'); if (cancelDownloadsBtn) { cancelDownloadsBtn.addEventListener('click', () => this.handleCancelDownloads()); } const updateDepsBtn = document.getElementById('updateDepsBtn'); if (updateDepsBtn) { updateDepsBtn.addEventListener('click', () => this.handleUpdateDependencies()); } } // Set up input event listeners setupInputEventListeners() { // URL input - no paste handler needed, user clicks "Add Video" button const urlInput = document.getElementById('urlInput'); if (urlInput) { // Optional: could add real-time validation feedback here } // Configuration inputs const defaultQuality = document.getElementById('defaultQuality'); if (defaultQuality) { defaultQuality.addEventListener('change', (e) => { const newValue = e.target.value; this.state.updateConfig({ defaultQuality: newValue }); // Ask if user wants to update existing videos this.promptUpdateExistingVideos('quality', newValue); }); } const defaultFormat = document.getElementById('defaultFormat'); if (defaultFormat) { defaultFormat.addEventListener('change', (e) => { const newValue = e.target.value; this.state.updateConfig({ defaultFormat: newValue }); // Ask if user wants to update existing videos this.promptUpdateExistingVideos('format', newValue); }); } } // Set up video list event listeners setupVideoListEventListeners() { const videoList = document.getElementById('videoList'); if (videoList) { videoList.addEventListener('click', (e) => this.handleVideoListClick(e)); videoList.addEventListener('change', (e) => this.handleVideoListChange(e)); this.setupDragAndDrop(videoList); } } // Set up drag-and-drop reordering setupDragAndDrop(videoList) { let draggedElement = null; let draggedVideoId = null; videoList.addEventListener('dragstart', (e) => { const videoItem = e.target.closest('.video-item'); if (!videoItem) return; draggedElement = videoItem; draggedVideoId = videoItem.dataset.videoId; videoItem.classList.add('opacity-50'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', videoItem.innerHTML); }); videoList.addEventListener('dragover', (e) => { e.preventDefault(); const videoItem = e.target.closest('.video-item'); if (!videoItem || videoItem === draggedElement) return; e.dataTransfer.dropEffect = 'move'; // Visual feedback - show where it will drop const rect = videoItem.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; if (e.clientY < midpoint) { videoItem.classList.add('border-t-2', 'border-[#155dfc]'); videoItem.classList.remove('border-b-2'); } else { videoItem.classList.add('border-b-2', 'border-[#155dfc]'); videoItem.classList.remove('border-t-2'); } }); videoList.addEventListener('dragleave', (e) => { const videoItem = e.target.closest('.video-item'); if (videoItem) { videoItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]'); } }); videoList.addEventListener('drop', (e) => { e.preventDefault(); const targetItem = e.target.closest('.video-item'); if (!targetItem || !draggedVideoId) return; const targetVideoId = targetItem.dataset.videoId; // Calculate drop position const rect = targetItem.getBoundingClientRect(); const midpoint = rect.top + rect.height / 2; const dropBefore = e.clientY < midpoint; // Reorder in state this.handleVideoReorder(draggedVideoId, targetVideoId, dropBefore); // Clean up visual feedback targetItem.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]'); }); videoList.addEventListener('dragend', (e) => { const videoItem = e.target.closest('.video-item'); if (videoItem) { videoItem.classList.remove('opacity-50'); } // Clean up all visual feedback document.querySelectorAll('.video-item').forEach(item => { item.classList.remove('border-t-2', 'border-b-2', 'border-[#155dfc]'); }); draggedElement = null; draggedVideoId = null; }); } handleVideoReorder(draggedId, targetId, insertBefore) { const videos = this.state.getVideos(); const draggedIndex = videos.findIndex(v => v.id === draggedId); const targetIndex = videos.findIndex(v => v.id === targetId); if (draggedIndex === -1 || targetIndex === -1) return; let newIndex = targetIndex; if (draggedIndex < targetIndex && !insertBefore) { newIndex = targetIndex; } else if (draggedIndex > targetIndex && insertBefore) { newIndex = targetIndex; } else if (insertBefore) { newIndex = targetIndex; } else { newIndex = targetIndex + 1; } this.state.reorderVideos(draggedIndex, newIndex); } // Handle clicks in video list (checkboxes, delete buttons) handleVideoListClick(event) { const target = event.target; const videoItem = target.closest('.video-item'); if (!videoItem) return; const videoId = videoItem.dataset.videoId; if (!videoId) return; // Handle checkbox click if (target.closest('.video-checkbox')) { event.preventDefault(); this.toggleVideoSelection(videoId); return; } // Handle delete button click (if we add one later) if (target.closest('.delete-video-btn')) { event.preventDefault(); this.handleRemoveVideo(videoId); return; } } // Handle dropdown changes in video list (quality, format) handleVideoListChange(event) { const target = event.target; const videoItem = target.closest('.video-item'); if (!videoItem) return; const videoId = videoItem.dataset.videoId; if (!videoId) return; // Handle quality dropdown change if (target.classList.contains('quality-select')) { const quality = target.value; this.state.updateVideo(videoId, { quality }); console.log(`Updated video ${videoId} quality to ${quality}`); return; } // Handle format dropdown change if (target.classList.contains('format-select')) { const format = target.value; this.state.updateVideo(videoId, { format }); console.log(`Updated video ${videoId} format to ${format}`); return; } } // Toggle video selection toggleVideoSelection(videoId) { this.state.toggleVideoSelection(videoId); this.updateVideoCheckbox(videoId); } // Update checkbox visual state updateVideoCheckbox(videoId) { const videoItem = document.querySelector(`[data-video-id="${videoId}"]`); if (!videoItem) return; const checkbox = videoItem.querySelector('.video-checkbox'); if (!checkbox) return; const isSelected = this.state.ui.selectedVideos.includes(videoId); checkbox.setAttribute('aria-checked', isSelected ? 'true' : 'false'); // Update checkbox SVG const svg = checkbox.querySelector('svg'); if (svg) { if (isSelected) { svg.innerHTML = ` `; } else { svg.innerHTML = ``; } } } // Remove video from list handleRemoveVideo(videoId) { try { const video = this.state.getVideo(videoId); if (video && confirm(`Remove "${video.getDisplayName()}"?`)) { this.state.removeVideo(videoId); this.updateStatusMessage('Video removed'); } } catch (error) { console.error('Error removing video:', error); this.showError(`Failed to remove video: ${error.message}`); } } // Event handlers async handleAddVideo() { const urlInput = document.getElementById('urlInput'); const inputText = urlInput?.value.trim(); if (!inputText) { this.showError('Please enter a URL'); return; } try { this.updateStatusMessage('Adding videos...'); // Validate URLs const validation = window.URLValidator.validateMultipleUrls(inputText); if (validation.invalid.length > 0) { this.showError(`Invalid URLs found: ${validation.invalid.join(', ')}`); return; } if (validation.valid.length === 0) { this.showError('No valid URLs found'); return; } // Add videos to state const results = await this.state.addVideosFromUrls(validation.valid); // Clear input on success if (urlInput) { urlInput.value = ''; } // Show results const successCount = results.successful.length; const duplicateCount = results.duplicates.length; const failedCount = results.failed.length; let message = `Added ${successCount} video(s)`; if (duplicateCount > 0) { message += `, ${duplicateCount} duplicate(s) skipped`; } if (failedCount > 0) { message += `, ${failedCount} failed`; } this.updateStatusMessage(message); } catch (error) { console.error('Error adding videos:', error); this.showError(`Failed to add videos: ${error.message}`); } } async handleImportUrls() { if (!window.electronAPI) { this.showError('File import requires Electron environment'); return; } try { // Implementation would use Electron file dialog this.updateStatusMessage('Import URLs functionality coming soon'); } catch (error) { this.showError(`Failed to import URLs: ${error.message}`); } } async handleSelectSavePath() { if (!window.IPCManager || !window.IPCManager.isAvailable()) { this.showError('Path selection requires Electron environment'); return; } try { this.updateStatusMessage('Select download directory...'); const result = await window.IPCManager.selectSaveDirectory(); if (result && result.success && result.path) { this.state.updateConfig({ savePath: result.path }); await this.ensureSaveDirectoryExists(); // Auto-create directory this.updateSavePathDisplay(); this.updateStatusMessage(`Save path set to: ${result.path}`); } else if (result && result.error) { this.showError(result.error); } else { this.updateStatusMessage('No directory selected'); } } catch (error) { console.error('Error selecting save path:', error); this.showError(`Failed to select save path: ${error.message}`); } } async handleSelectCookieFile() { if (!window.IPCManager || !window.IPCManager.isAvailable()) { this.showError('File selection requires Electron environment'); return; } try { this.updateStatusMessage('Select cookie file...'); const result = await window.IPCManager.selectCookieFile(); if (result && result.success && result.path) { this.state.updateConfig({ cookieFile: result.path }); this.updateStatusMessage(`Cookie file set: ${result.path}`); // Update UI to show selected cookie file const cookieFilePathElement = document.getElementById('cookieFilePath'); if (cookieFilePathElement) { const fileName = result.path.split('/').pop() || result.path.split('\\').pop(); cookieFilePathElement.textContent = fileName; cookieFilePathElement.title = result.path; } } else if (result && result.error) { this.showError(result.error); } else { this.updateStatusMessage('No file selected'); } } catch (error) { console.error('Error selecting cookie file:', error); this.showError(`Failed to select cookie file: ${error.message}`); } } handleClearList() { if (this.state.getVideos().length === 0) { this.updateStatusMessage('No videos to clear'); return; } const removedVideos = this.state.clearVideos(); this.updateStatusMessage(`Cleared ${removedVideos.length} video(s)`); } async handleDownloadVideos() { // Check if IPC is available if (!window.IPCManager || !window.IPCManager.isAvailable()) { this.showError('Download functionality requires Electron environment'); return; } // Get downloadable videos (either selected or all ready videos) const selectedVideos = this.state.getSelectedVideos().filter(v => v.isDownloadable()); const videos = selectedVideos.length > 0 ? selectedVideos : this.state.getVideos().filter(v => v.isDownloadable()); if (videos.length === 0) { this.showError('No videos ready for download'); return; } // Validate save path if (!this.state.config.savePath) { this.showError('Please select a save directory first'); return; } this.state.updateUI({ isDownloading: true }); this.updateStatusMessage(`Starting parallel download of ${videos.length} video(s)...`); // Set up download progress listener window.IPCManager.onDownloadProgress('app', (progressData) => { this.handleDownloadProgress(progressData); }); // PARALLEL DOWNLOADS: Start all downloads simultaneously // The DownloadManager will handle concurrency limits automatically console.log(`Starting ${videos.length} downloads in parallel...`); const downloadPromises = videos.map(async (video) => { try { // Update video status to downloading this.state.updateVideo(video.id, { status: 'downloading', progress: 0 }); const result = await window.IPCManager.downloadVideo({ videoId: video.id, url: video.url, quality: video.quality, format: video.format, savePath: this.state.config.savePath, cookieFile: this.state.config.cookieFile }); if (result.success) { this.state.updateVideo(video.id, { status: 'completed', progress: 100, filename: result.filename }); // Show notification for successful download this.showDownloadNotification(video, 'success'); return { success: true, video }; } else { this.state.updateVideo(video.id, { status: 'error', error: result.error || 'Download failed' }); // Show notification for failed download this.showDownloadNotification(video, 'error', result.error); return { success: false, video, error: result.error }; } } catch (error) { console.error(`Error downloading video ${video.id}:`, error); this.state.updateVideo(video.id, { status: 'error', error: error.message }); return { success: false, video, error: error.message }; } }); // Wait for all downloads to complete const results = await Promise.all(downloadPromises); // Count successes and failures const successCount = results.filter(r => r.success).length; const failedCount = results.filter(r => !r.success).length; // Clean up progress listener window.IPCManager.removeDownloadProgressListener('app'); this.state.updateUI({ isDownloading: false }); // Show final status let message = `Download complete: ${successCount} succeeded`; if (failedCount > 0) { message += `, ${failedCount} failed`; } this.updateStatusMessage(message); } // Handle download progress updates from IPC handleDownloadProgress(progressData) { const { url, progress, status, stage, message } = progressData; // Find video by URL const video = this.state.getVideos().find(v => v.url === url); if (!video) return; // Update video progress this.state.updateVideo(video.id, { progress: Math.round(progress), status: status || 'downloading' }); } // Show download notification async showDownloadNotification(video, type, errorMessage = null) { if (!window.electronAPI) return; try { const notificationOptions = { title: type === 'success' ? 'Download Complete' : 'Download Failed', message: type === 'success' ? `${video.getDisplayName()}` : `${video.getDisplayName()}: ${errorMessage || 'Unknown error'}`, sound: true }; await window.electronAPI.showNotification(notificationOptions); } catch (error) { console.warn('Failed to show notification:', error); } } async handleCancelDownloads() { const activeDownloads = this.state.getVideosByStatus('downloading').length + this.state.getVideosByStatus('converting').length; if (activeDownloads === 0) { this.updateStatusMessage('No active downloads to cancel'); return; } if (!window.IPCManager || !window.IPCManager.isAvailable()) { this.showError('Cancel functionality requires Electron environment'); return; } try { this.updateStatusMessage(`Cancelling ${activeDownloads} active download(s)...`); // Cancel all conversions via IPC await window.electronAPI.cancelAllConversions(); // Update video statuses to ready const downloadingVideos = this.state.getVideosByStatus('downloading'); const convertingVideos = this.state.getVideosByStatus('converting'); [...downloadingVideos, ...convertingVideos].forEach(video => { this.state.updateVideo(video.id, { status: 'ready', progress: 0, error: 'Cancelled by user' }); }); this.state.updateUI({ isDownloading: false }); this.updateStatusMessage('Downloads cancelled'); } catch (error) { console.error('Error cancelling downloads:', error); this.showError(`Failed to cancel downloads: ${error.message}`); } } async handleUpdateDependencies() { if (!window.IPCManager || !window.IPCManager.isAvailable()) { this.showError('Update functionality requires Electron environment'); return; } const btn = document.getElementById('updateDepsBtn'); const originalBtnHTML = btn ? btn.innerHTML : ''; try { // Show loading state this.updateStatusMessage('Checking binary versions...'); if (btn) { btn.disabled = true; btn.innerHTML = 'Checking...'; } const versions = await window.IPCManager.checkBinaryVersions(); // Handle both ytDlp (from main.js) and ytdlp (legacy) formats const ytdlp = versions.ytDlp || versions.ytdlp; const ffmpeg = versions.ffmpeg; if (versions && (ytdlp || ffmpeg)) { // Update both button status and version display const ytdlpMissing = !ytdlp || !ytdlp.available; const ffmpegMissing = !ffmpeg || !ffmpeg.available; if (ytdlpMissing || ffmpegMissing) { this.updateDependenciesButtonStatus('missing'); this.updateBinaryVersionDisplay(null); } else { this.updateDependenciesButtonStatus('ok'); // Normalize the format for display const normalizedVersions = { ytdlp: ytdlp, ffmpeg: ffmpeg }; this.updateBinaryVersionDisplay(normalizedVersions); // Show dialog if updates are available if (ytdlp.updateAvailable) { this.showInfo({ title: 'Update Available', message: `A newer version of yt-dlp is available:\nInstalled: ${ytdlp.version}\nLatest: ${ytdlp.latestVersion || 'newer version'}\n\nPlease run 'npm run setup' to update.` }); } } } else { this.showError('Could not check binary versions'); } } catch (error) { console.error('Error checking dependencies:', error); this.showError(`Failed to check dependencies: ${error.message}`); } finally { // Restore button state if (btn) { btn.disabled = false; btn.innerHTML = originalBtnHTML || 'Check for Updates'; } } } // State change handlers onVideoAdded(data) { this.renderVideoList(); this.updateStatsDisplay(); } onVideoRemoved(data) { this.renderVideoList(); this.updateStatsDisplay(); } onVideoUpdated(data) { this.updateVideoElement(data.video); this.updateStatsDisplay(); } onVideosReordered(data) { // Re-render entire list to reflect new order this.renderVideoList(); console.log('Video order updated:', data); } onVideosCleared(data) { this.renderVideoList(); this.updateStatsDisplay(); } onConfigUpdated(data) { this.updateConfigUI(data.config); } // UI update methods updateSavePathDisplay() { const savePathElement = document.getElementById('savePath'); if (savePathElement) { savePathElement.textContent = this.state.config.savePath; } } initializeDropdowns() { // Set dropdown values from config const defaultQuality = document.getElementById('defaultQuality'); if (defaultQuality) { defaultQuality.value = this.state.config.defaultQuality; } const defaultFormat = document.getElementById('defaultFormat'); if (defaultFormat) { defaultFormat.value = this.state.config.defaultFormat; } } initializeVideoList() { this.renderVideoList(); } renderVideoList() { const videoList = document.getElementById('videoList'); if (!videoList) return; const videos = this.state.getVideos(); // Clear all existing videos (including mockups) videoList.innerHTML = ''; // If no videos, show empty state if (videos.length === 0) { videoList.innerHTML = `

No videos yet

Paste YouTube or Vimeo URLs above to get started

`; return; } // Render each video videos.forEach(video => { const videoElement = this.createVideoElement(video); videoList.appendChild(videoElement); }); } createVideoElement(video) { const div = document.createElement('div'); div.className = 'video-item grid grid-cols-[40px_40px_1fr_120px_100px_120px_100px_40px] gap-4 items-center p-2 rounded bg-[#314158] hover:bg-[#3a4a68] transition-colors duration-200'; div.dataset.videoId = video.id; div.setAttribute('draggable', 'true'); // Make video item draggable div.innerHTML = `
${video.isFetchingMetadata ? `
` : video.thumbnail ? `${video.getDisplayName()}` : `
` }
${video.getDisplayName()}
${video.isFetchingMetadata ? `
Fetching info...
` : '' }
${video.duration || '--:--'}
${this.getStatusText(video)}
`; return div; } getStatusText(video) { switch (video.status) { case 'downloading': return `Downloading ${video.progress || 0}%`; case 'converting': return `Converting ${video.progress || 0}%`; case 'completed': return 'Completed'; case 'error': return 'Error'; case 'ready': default: return 'Ready'; } } updateVideoElement(video) { const videoElement = document.querySelector(`[data-video-id="${video.id}"]`); if (!videoElement) return; // Update thumbnail - show loading spinner if fetching metadata const thumbnailContainer = videoElement.querySelector('.w-16.h-12'); if (thumbnailContainer) { if (video.isFetchingMetadata) { thumbnailContainer.innerHTML = `
`; } else if (video.thumbnail) { thumbnailContainer.innerHTML = `${video.getDisplayName()}`; } else { thumbnailContainer.innerHTML = `
`; } } // Update title and loading message const titleContainer = videoElement.querySelector('.min-w-0.flex-1'); if (titleContainer) { const titleElement = titleContainer.querySelector('.text-sm.text-white.truncate'); if (titleElement) { titleElement.textContent = video.getDisplayName(); } // Update or remove "Fetching info..." message const existingLoadingMsg = titleContainer.querySelector('.text-xs.text-\\[\\#155dfc\\]'); if (video.isFetchingMetadata && !existingLoadingMsg) { const loadingMsg = document.createElement('div'); loadingMsg.className = 'text-xs text-[#155dfc] animate-pulse'; loadingMsg.textContent = 'Fetching info...'; titleContainer.appendChild(loadingMsg); } else if (!video.isFetchingMetadata && existingLoadingMsg) { existingLoadingMsg.remove(); } } // Update duration const durationElement = videoElement.querySelector('.text-sm.text-\\[\\#cad5e2\\].text-center'); if (durationElement) { durationElement.textContent = video.duration || '--:--'; } // Update quality dropdown const qualitySelect = videoElement.querySelector('.quality-select'); if (qualitySelect) { qualitySelect.value = video.quality; } // Update format dropdown const formatSelect = videoElement.querySelector('.format-select'); if (formatSelect) { formatSelect.value = video.format; } // Update status badge with progress const statusBadge = videoElement.querySelector('.status-badge'); if (statusBadge) { statusBadge.className = `status-badge ${video.status}`; statusBadge.textContent = this.getStatusText(video); // Add progress bar for downloading/converting states if (video.status === 'downloading' || video.status === 'converting') { const progress = video.progress || 0; statusBadge.style.background = `linear-gradient(to right, #155dfc ${progress}%, #314158 ${progress}%)`; } else { statusBadge.style.background = ''; } } } updateStatsDisplay() { const stats = this.state.getStats(); // Update UI with current statistics } updateConfigUI(config) { this.updateSavePathDisplay(); this.initializeDropdowns(); } updateStatusMessage(message) { const statusElement = document.getElementById('statusMessage'); if (statusElement) { statusElement.textContent = message; } // Auto-clear success messages if (!message.toLowerCase().includes('error') && !message.toLowerCase().includes('failed')) { setTimeout(() => { if (statusElement && statusElement.textContent === message) { statusElement.textContent = 'Ready to download videos'; } }, 5000); } } showError(message) { this.updateStatusMessage(`Error: ${message}`); console.error('App Error:', message); this.eventBus.emit('app:error', { type: 'user', message }); } displayError(errorData) { const message = errorData.error?.message || errorData.message || 'An error occurred'; this.updateStatusMessage(`Error: ${message}`); } /** * Prompt user to update existing videos with new default settings * @param {string} property - 'quality' or 'format' * @param {string} newValue - New default value */ promptUpdateExistingVideos(property, newValue) { const allVideos = this.state.getVideos(); const selectedVideos = this.state.getSelectedVideos(); // Determine which videos to potentially update // If videos are selected, only update those; otherwise, update all downloadable videos const videosToCheck = selectedVideos.length > 0 ? selectedVideos.filter(v => v.status === 'ready' || v.status === 'error') : allVideos.filter(v => v.status === 'ready' || v.status === 'error'); // Only prompt if there are videos that could be updated if (videosToCheck.length === 0) { return; } const propertyName = property === 'quality' ? 'quality' : 'format'; const scope = selectedVideos.length > 0 ? 'selected' : 'all'; const message = `Update ${scope} ${videosToCheck.length} video(s) in the list to use ${propertyName}: ${newValue}?`; if (confirm(message)) { let updatedCount = 0; videosToCheck.forEach(video => { this.state.updateVideo(video.id, { [property]: newValue }); updatedCount++; }); this.updateStatusMessage(`Updated ${updatedCount} ${scope} video(s) with new ${propertyName}: ${newValue}`); this.renderVideoList(); } } // Keyboard navigation initializeKeyboardNavigation() { // Basic keyboard navigation setup document.addEventListener('keydown', (e) => { if (e.ctrlKey || e.metaKey) { switch (e.key) { case 'a': e.preventDefault(); this.state.selectAllVideos(); break; case 'd': e.preventDefault(); this.handleDownloadVideos(); break; } } }); } // Ensure save directory exists async ensureSaveDirectoryExists() { const savePath = this.state.config.savePath; if (!savePath || !window.electronAPI) return; try { const result = await window.electronAPI.createDirectory(savePath); if (!result.success) { console.warn('Failed to create save directory:', result.error); } else { console.log('Save directory ready:', result.path); } } catch (error) { console.error('Error creating directory:', error); } } // Check binary status and validate with blocking dialog if missing async checkAndValidateBinaries() { if (!window.IPCManager || !window.IPCManager.isAvailable()) return; try { const versions = await window.IPCManager.checkBinaryVersions(); // Handle both ytDlp (from main.js) and ytdlp (legacy) formats const ytdlp = versions.ytDlp || versions.ytdlp; const ffmpeg = versions.ffmpeg; if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) { this.updateDependenciesButtonStatus('missing'); this.updateBinaryVersionDisplay(null); // Show blocking dialog to warn user await this.showMissingBinariesDialog(ytdlp, ffmpeg); } else { this.updateDependenciesButtonStatus('ok'); // Normalize the format for display const normalizedVersions = { ytdlp: ytdlp, ffmpeg: ffmpeg }; this.updateBinaryVersionDisplay(normalizedVersions); } } catch (error) { console.error('Error checking binary status:', error); // Set missing status on error this.updateDependenciesButtonStatus('missing'); this.updateBinaryVersionDisplay(null); // Show dialog on error too await this.showMissingBinariesDialog(null, null); } } // Show blocking dialog when binaries are missing async showMissingBinariesDialog(ytdlp, ffmpeg) { // Determine which binaries are missing const missingBinaries = []; if (!ytdlp || !ytdlp.available) missingBinaries.push('yt-dlp'); if (!ffmpeg || !ffmpeg.available) missingBinaries.push('ffmpeg'); const missingList = missingBinaries.length > 0 ? missingBinaries.join(', ') : 'yt-dlp and ffmpeg'; if (window.electronAPI && window.electronAPI.showErrorDialog) { // Use native Electron dialog await window.electronAPI.showErrorDialog({ title: 'Required Binaries Missing', message: `The following required binaries are missing: ${missingList}`, detail: 'Please run "npm run setup" in the terminal to download the required binaries.\n\n' + 'Without these binaries, GrabZilla cannot download or convert videos.\n\n' + 'After running "npm run setup", restart the application.' }); } else { // Fallback to browser alert alert( `โš ๏ธ Required Binaries Missing\n\n` + `Missing: ${missingList}\n\n` + `Please run "npm run setup" to download the required binaries.\n\n` + `Without these binaries, GrabZilla cannot download or convert videos.` ); } } // Check binary status and update UI (non-blocking version for updates) async checkBinaryStatus() { if (!window.IPCManager || !window.IPCManager.isAvailable()) return; try { const versions = await window.IPCManager.checkBinaryVersions(); // Handle both ytDlp (from main.js) and ytdlp (legacy) formats const ytdlp = versions.ytDlp || versions.ytdlp; const ffmpeg = versions.ffmpeg; if (!versions || !ytdlp || !ytdlp.available || !ffmpeg || !ffmpeg.available) { this.updateDependenciesButtonStatus('missing'); this.updateBinaryVersionDisplay(null); } else { this.updateDependenciesButtonStatus('ok'); // Normalize the format for display const normalizedVersions = { ytdlp: ytdlp, ffmpeg: ffmpeg }; this.updateBinaryVersionDisplay(normalizedVersions); } } catch (error) { console.error('Error checking binary status:', error); // Set missing status on error this.updateDependenciesButtonStatus('missing'); this.updateBinaryVersionDisplay(null); } } updateBinaryVersionDisplay(versions) { const statusMessage = document.getElementById('statusMessage'); const ytdlpVersionNumber = document.getElementById('ytdlpVersionNumber'); const ytdlpUpdateBadge = document.getElementById('ytdlpUpdateBadge'); const ffmpegVersionNumber = document.getElementById('ffmpegVersionNumber'); const lastUpdateCheck = document.getElementById('lastUpdateCheck'); if (!versions) { // Binaries missing if (statusMessage) statusMessage.textContent = 'Ready to download videos - Binaries required'; if (ytdlpVersionNumber) ytdlpVersionNumber.textContent = 'missing'; if (ffmpegVersionNumber) ffmpegVersionNumber.textContent = 'missing'; if (ytdlpUpdateBadge) ytdlpUpdateBadge.classList.add('hidden'); if (lastUpdateCheck) lastUpdateCheck.textContent = '--'; return; } // Update yt-dlp version if (ytdlpVersionNumber) { const ytdlpVersion = versions.ytdlp?.version || 'unknown'; ytdlpVersionNumber.textContent = ytdlpVersion; } // Show/hide update badge for yt-dlp if (ytdlpUpdateBadge) { if (versions.ytdlp?.updateAvailable) { ytdlpUpdateBadge.classList.remove('hidden'); ytdlpUpdateBadge.title = `Update available: ${versions.ytdlp.latestVersion || 'newer version'}`; } else { ytdlpUpdateBadge.classList.add('hidden'); } } // Update ffmpeg version if (ffmpegVersionNumber) { const ffmpegVersion = versions.ffmpeg?.version || 'unknown'; ffmpegVersionNumber.textContent = ffmpegVersion; } // Update last check timestamp if (lastUpdateCheck) { const now = new Date(); const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); lastUpdateCheck.textContent = `checked ${timeString}`; lastUpdateCheck.title = `Last update check: ${now.toLocaleString()}`; } // Update status message if (statusMessage) { const hasUpdates = versions.ytdlp?.updateAvailable; statusMessage.textContent = hasUpdates ? 'Update available for yt-dlp' : 'Ready to download videos'; } } updateDependenciesButtonStatus(status) { const btn = document.getElementById('updateDepsBtn'); if (!btn) return; if (status === 'missing') { btn.classList.add('bg-red-600', 'animate-pulse'); btn.classList.remove('bg-[#314158]'); btn.innerHTML = 'โš ๏ธ Required'; } else { btn.classList.remove('bg-red-600', 'animate-pulse'); btn.classList.add('bg-[#314158]'); btn.innerHTML = 'Check for Updates'; } } // State persistence async loadState() { try { const savedState = localStorage.getItem('grabzilla-state'); if (savedState) { const data = JSON.parse(savedState); this.state.fromJSON(data); console.log('โœ… Loaded saved state'); // Re-render video list to show restored videos this.renderVideoList(); this.updateSavePathDisplay(); this.updateStatsDisplay(); } } catch (error) { console.warn('Failed to load saved state:', error); } } async saveState() { try { const stateData = this.state.toJSON(); localStorage.setItem('grabzilla-state', JSON.stringify(stateData)); } catch (error) { console.warn('Failed to save state:', error); } } // Lifecycle methods handleInitializationError(error) { // Show fallback UI or error message const statusElement = document.getElementById('statusMessage'); if (statusElement) { statusElement.textContent = 'Failed to initialize application'; } } destroy() { // Clean up resources if (this.state) { this.saveState(); } // Remove event listeners this.eventBus?.removeAllListeners(); this.initialized = false; console.log('๐Ÿงน GrabZilla app destroyed'); } } // Initialize function to be called after all scripts are loaded window.initializeGrabZilla = function() { window.app = new GrabZillaApp(); window.app.init(); }; // Auto-save state on page unload window.addEventListener('beforeunload', () => { if (window.app?.initialized) { window.app.saveState(); } }); // Export the app class if (typeof module !== 'undefined' && module.exports) { module.exports = GrabZillaApp; } else { window.GrabZillaApp = GrabZillaApp; }