const { ipcRenderer } = require('electron'); const AppState = require('./AppState'); const FileListManager = require('./FileListManager'); const TagManager = require('./TagManager'); const EpisodeManager = require('./EpisodeManager'); const SearchManager = require('./SearchManager'); const FileManager = require('./FileManager'); const ModalManager = require('./ModalManager'); const ProgressManager = require('./ProgressManager'); /** * UIManager - Main coordinator for UI functionality */ class UIManager { constructor() { // Initialize managers this.appState = new AppState(); this.fileManager = new FileManager(); this.modalManager = new ModalManager(); // Get DOM elements this.selectDirBtn = document.getElementById('select-dir-btn'); this.selectedDirEl = document.getElementById('selected-dir'); this.searchInput = document.getElementById('search-input'); this.searchResultsEl = document.getElementById('search-results'); this.fileListEl = document.getElementById('file-list'); this.showDetailsEl = document.getElementById('show-details'); this.progressContainer = document.getElementById('progress-container'); this.progressText = document.getElementById('progress-text'); this.progressCount = document.getElementById('progress-count'); // Initialize UI managers this.fileListManager = new FileListManager(this.fileListEl); this.tagManager = new TagManager(); this.episodeManager = new EpisodeManager(); this.searchManager = new SearchManager(this.searchInput, this.searchResultsEl); this.progressManager = new ProgressManager( this.progressContainer, this.progressText, this.progressCount ); // Set up event listeners this._setupEventListeners(); } /** * Set up all event listeners * @private */ _setupEventListeners() { // Select directory button this.selectDirBtn.addEventListener('click', () => this._handleSelectDirectory()); // Search input with debounce this.searchInput.addEventListener('input', this._debounce(() => this._handleSearch(), 300)); // Episode number editing document.addEventListener('click', (e) => { if (e.target.classList.contains('episode-number')) { e.stopPropagation(); this.episodeManager.makeEpisodeRangeEditable(e.target); } }); // Begin mapping button const beginMappingBtn = document.getElementById('begin-mapping-btn'); if (beginMappingBtn) { beginMappingBtn.addEventListener('click', () => this._handleBeginMapping()); } // Floating circle (play button) to move all tagged files const taggedCircle = document.getElementById('tagged-circle'); if (taggedCircle) { taggedCircle.addEventListener('click', () => this._handleMoveAllTaggedFiles()); } // Listen for scan progress updates from main process ipcRenderer.on('scan-progress', (event, { current, total, fileName }) => { this.progressManager.updateProgress(current, total, fileName); }); // Listen for auto-select directory message window.addEventListener('message', (event) => { if (event.data.type === 'auto-select-directory') { this._handleAutoSelectDirectory(event.data.directory); } }); // Listen for auto-select directory from main process ipcRenderer.on('auto-select-directory', (event, directory) => { this._handleAutoSelectDirectory(directory); }); // Initialize tagged count on page load this._updateTaggedCount(); } /** * Handle select directory * @private */ async _handleSelectDirectory() { try { const result = await this.fileManager.selectDirectory(); if (result.success) { const directory = result.directory; this.appState.setCurrentDirectory(directory); this.selectedDirEl.textContent = `Selected: ${directory}`; // Log audit event for directory selection await this._logAuditEvent('select_directory', { directory: directory }); // Scan the directory for media files await this._scanDirectory(directory); } else { throw new Error(result.error); } } catch (error) { console.error('Error selecting directory:', error); alert(`Error: ${error.message}`); } } /** * Handle auto-select directory * @param {string} directory - Directory path * @private */ async _handleAutoSelectDirectory(directory) { try { console.log('Auto-selecting directory:', directory); this.appState.setCurrentDirectory(directory); this.selectedDirEl.textContent = `Selected: ${directory}`; // Log audit event for directory selection await this._logAuditEvent('select_directory', { directory: directory }); // Scan the directory for media files await this._scanDirectory(directory); } catch (error) { console.error('Error auto-selecting directory:', error); alert(`Error: ${error.message}`); } } /** * Open a specific directory * @param {string} directory - Directory path */ async openDirectory(directory) { try { console.log('Opening directory:', directory); // Set the current directory this.appState.setCurrentDirectory(directory); this.selectedDirEl.textContent = `Selected: ${directory}`; // Log audit event for directory selection await this._logAuditEvent('select_directory', { directory: directory }); // Scan the directory for media files await this._scanDirectory(directory); } catch (error) { console.error('Error opening directory:', error); alert(`Error: ${error.message}`); } } /** * Handle search * @private */ async _handleSearch() { const query = this.searchInput.value.trim(); await this.searchManager.searchShows(query); } /** * Scan directory * @param {string} directoryPath - Directory path * @private */ async _scanDirectory(directoryPath) { try { // Show progress bar this.progressManager.showProgress(); const result = await this.fileManager.scanDirectory(directoryPath); // Hide progress bar this.progressManager.hideProgress(); if (result.success) { this.appState.setCurrentFiles(result.files); this.fileListManager.displayFiles(result.files); } else { throw new Error(result.error); } } catch (error) { this.progressManager.hideProgress(); console.error('Error scanning directory:', error); alert(`Error scanning directory: ${error.message}`); } } /** * Handle begin mapping * @private */ async _handleBeginMapping() { const btn = document.getElementById('begin-mapping-btn'); // Get all media file items (not folders) const fileItems = this.fileListManager.getMediaFileItems(); if (fileItems.length === 0) { alert('No media files to map. Please select a directory first.'); return; } if (!this.appState.getCurrentDirectory()) { alert('No directory selected.'); return; } // Disable button during processing btn.disabled = true; btn.textContent = 'Mapping...'; try { // Collect file data with episode ranges from UI order const filesToMap = this.fileManager.collectFileData(this.fileListEl); // Get TVDB ID from current show if selected const tvdbId = this.appState.getCurrentShow() ? this.appState.getCurrentShow().id : null; const result = await this.fileManager.beginMapping( this.appState.getCurrentDirectory(), filesToMap, tvdbId ); if (result.success) { alert('Mapping Complete!'); // Update currentDirectory if it changed (show folder was renamed) const newDir = result.newDirectory || this.appState.getCurrentDirectory(); this.appState.setCurrentDirectory(newDir); // Refresh the file list to show new names await this.openDirectory(newDir); } else { alert('Mapping failed: ' + (result.error || 'Unknown error')); } } catch (error) { console.error('Mapping error:', error); alert('Mapping failed: ' + error.message); } finally { btn.disabled = false; btn.textContent = 'Begin Mapping'; } } /** * Handle move all tagged files * @private */ async _handleMoveAllTaggedFiles() { await this.tagManager.moveAllTaggedFiles( () => this._updateTaggedCount(), () => this.fileListManager.updateEpisodeNumbers(), () => this._checkEpisodeCountMatch() ); } /** * Update tagged count * @private */ _updateTaggedCount() { const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]'); const count = taggedItems.length; const taggedCircle = document.getElementById('tagged-circle'); const taggedCount = document.getElementById('tagged-count'); if (taggedCount) { taggedCount.textContent = count; } // Animate the circle position based on count if (taggedCircle) { if (count === 0) { // Move down out of sight taggedCircle.style.transform = 'translateY(100px)'; taggedCircle.style.bottom = '-100px'; } else { // Move up to show count (appears at the same spot at bottom right) taggedCircle.style.transform = 'translateY(0)'; taggedCircle.style.bottom = '20px'; } } } /** * Check episode count match * @private */ _checkEpisodeCountMatch() { // Count only actual media files (not folders) const mediaFiles = this.fileListManager.getMediaFileItems(); // Calculate total episode range (sum of all episode ranges) let totalEpisodeCount = 0; let lastEpisodeEnd = 0; mediaFiles.forEach((item, index) => { const episodeEl = item.querySelector('.episode-number'); if (episodeEl) { const episodeStart = parseInt(episodeEl.dataset.episodeStart || (index + 1)); const episodeEnd = parseInt(episodeEl.dataset.episodeEnd || episodeStart); // Calculate episodes in this range const rangeSize = episodeEnd - episodeStart + 1; totalEpisodeCount += rangeSize; // Track the last episode end for sequential checking if (index === 0 || episodeEnd > lastEpisodeEnd) { lastEpisodeEnd = episodeEnd; } } }); // Use lastEpisodeEnd for comparison (handles ranges properly) const selectedSeasonEpisodeCount = this.appState.getSelectedSeasonEpisodeCount(); if (selectedSeasonEpisodeCount > 0 && lastEpisodeEnd === selectedSeasonEpisodeCount) { // Perfect match - add green outline this.fileListEl.style.border = '3px solid #28a745'; this.fileListEl.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.4)'; } else { // No match or no season selected - remove green outline this.fileListEl.style.border = ''; this.fileListEl.style.boxShadow = ''; } } /** * Log audit event * @param {string} action - Action type * @param {Object} details - Event details * @private */ async _logAuditEvent(action, details) { const currentDirectory = this.appState.getCurrentDirectory(); if (currentDirectory) { await this.fileManager.logAuditEvent(currentDirectory, action, details); } } /** * Debounce function * @param {Function} func - Function to debounce * @param {number} wait - Wait time in ms * @returns {Function} Debounced function * @private */ _debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } /** * Display created folder * @param {string} folderName - Folder name */ displayCreatedFolder(folderName) { // Check if folder entry already exists const existingFolder = document.querySelector(`.folder-item[data-folder-name="${folderName}"]`); if (existingFolder) { // Update the file count const countEl = existingFolder.querySelector('.folder-count'); if (countEl) { const currentCount = parseInt(countEl.textContent) || 0; countEl.textContent = currentCount + 1; } return; } // Create folder item element const folderItem = document.createElement('div'); folderItem.className = 'folder-item'; folderItem.setAttribute('data-folder-name', folderName); let displayName, folderIcon, folderColor; if (folderName === 'extra') { displayName = 'extras'; folderIcon = '📁'; folderColor = '#FFD700'; } else if (folderName === 'commentary') { displayName = 'commentary'; folderIcon = '💬'; folderColor = '#17a2b8'; } else if (folderName === 'delete') { displayName = 'delete'; folderIcon = '🗑️'; folderColor = '#dc3545'; } folderItem.innerHTML = `
${folderIcon}
${displayName}
1
`; folderItem.style.cssText = ` display: flex; align-items: center; gap: 10px; padding: 12px; margin-bottom: 8px; background-color: #16213e; border: 2px solid ${folderColor}; border-radius: 8px; cursor: pointer; `; // Insert at the top of the file list if (this.fileListEl.firstChild) { this.fileListEl.insertBefore(folderItem, this.fileListEl.firstChild); } else { this.fileListEl.appendChild(folderItem); } console.log(`Folder "${displayName}" displayed in UI`); } /** * Make file name editable * @param {HTMLElement} element - File name element */ async makeEditable(element) { // Prevent editing if already in edit mode if (element.contentEditable === 'true') return; // Store original text const originalText = element.textContent; // Make element editable element.contentEditable = 'true'; element.focus(); element.classList.add('editing'); // Select all text when editing starts const range = document.createRange(); range.selectNodeContents(element); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); // Handle saving when user finishes editing const saveEdit = async () => { if (element.textContent.trim() !== originalText.trim()) { // Send request to main process to rename the file try { const result = await this.fileManager.renameFile( element.dataset.filePath, element.textContent.trim() ); if (result.success) { console.log('File renamed successfully:', result.message); // Update the file name in the UI to reflect the change element.textContent = element.textContent.trim(); // Also update the data attribute to reflect the new path const oldPath = element.dataset.filePath; const newPath = oldPath.substring(0, oldPath.lastIndexOf(path.sep) + 1) + element.textContent.trim(); element.dataset.filePath = newPath; } else { console.error('Failed to rename file:', result.error); // Revert to original name on failure element.textContent = originalText; } } catch (error) { console.error('Error renaming file:', error); // Revert to original name on error element.textContent = originalText; } } // Clean up element.contentEditable = 'false'; element.classList.remove('editing'); }; // Save on Enter key or blur element.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); saveEdit(); } }); element.addEventListener('blur', saveEdit); } /** * Select show and display details * @param {Object} show - Show object */ async selectShow(show) { await this.searchManager.selectShow( show, (title) => { document.getElementById('show-title').textContent = title; }, (seasonsOrHtml) => { if (typeof seasonsOrHtml === 'string') { document.getElementById('seasons-container').innerHTML = seasonsOrHtml; } else { this.searchManager.displaySeasons(seasonsOrHtml, (seasonNumber) => this._handleSeasonSelect(show.id, seasonNumber), () => this._handleBackToSeasons() ); } } ); } /** * Handle season select * @param {string} showId - Show ID * @param {number} seasonNumber - Season number */ async _handleSeasonSelect(showId, seasonNumber) { await this.searchManager.fetchAndDisplayEpisodes( showId, seasonNumber, (episodes, error) => { if (error) { document.getElementById('seasons-container').innerHTML = `

Error loading episodes: ${error}

Note: This may be due to API limitations with the TVDB v4 API.

`; } else { this.searchManager.displayEpisodes( episodes, episodes.length, (count) => this.appState.setSelectedSeasonEpisodeCount(count), (episodes) => this.searchManager.updateFileListWithEpisodeInfo(episodes, this.appState.getCurrentFiles()) ); this._checkEpisodeCountMatch(); } } ); } /** * Handle back to seasons */ _handleBackToSeasons() { this.appState.setSelectedSeasonEpisodeCount(0); this._checkEpisodeCountMatch(); // Re-display seasons would happen here } /** * Debug problematic file * @param {string} filePath - File path */ async debugProblematicFile(filePath) { try { const result = await this.fileManager.logFileInfo(filePath); if (result.success) { console.log('File info:', result.fileInfo); } else { console.error('Failed to get file info:', result.error); } } catch (error) { console.error('Error debugging file:', error); } } } module.exports = UIManager;