# a simple window with a list of items in the queue, no checkboxes # button to remove an item from the queue # button to clear the queue from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QDialogButtonBox, QPushButton, QListWidget, QListWidgetItem, QFileIconProvider, QLabel, QWidget, QSizePolicy, QAbstractItemView, ) from PyQt6.QtCore import QFileInfo, Qt from abogen.constants import COLORS from copy import deepcopy from PyQt6.QtGui import QFontMetrics class ElidedLabel(QLabel): def __init__(self, text): super().__init__(text) self._full_text = text self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self.setTextFormat(Qt.TextFormat.PlainText) def setText(self, text): self._full_text = text super().setText(text) self.update() def resizeEvent(self, event): metrics = QFontMetrics(self.font()) elided = metrics.elidedText( self._full_text, Qt.TextElideMode.ElideRight, self.width() ) super().setText(elided) super().resizeEvent(event) def fullText(self): return self._full_text class QueueListItemWidget(QWidget): def __init__(self, file_name, char_count): super().__init__() layout = QHBoxLayout() layout.setContentsMargins(12, 0, 6, 0) layout.setSpacing(0) import os name_label = ElidedLabel(os.path.basename(file_name)) char_label = QLabel(f"Chars: {char_count}") char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") char_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) char_label.setSizePolicy( QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Preferred ) layout.addWidget(name_label, 1) layout.addWidget(char_label, 0) self.setLayout(layout) class DroppableQueueListWidget(QListWidget): def __init__(self, parent_dialog): super().__init__() self.parent_dialog = parent_dialog self.setAcceptDrops(True) # Overlay for drag hover self.drag_overlay = QLabel("", self) self.drag_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) self.drag_overlay.setStyleSheet( f"border:2px dashed {COLORS['BLUE_BORDER_HOVER']}; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG_HOVER']};" ) self.drag_overlay.setVisible(False) self.drag_overlay.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents, True ) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): file_path = url.toLocalFile().lower() if url.isLocalFile() and ( file_path.endswith(".txt") or file_path.endswith((".srt", ".ass", ".vtt")) ): self.drag_overlay.resize(self.size()) self.drag_overlay.setVisible(True) event.acceptProposedAction() return self.drag_overlay.setVisible(False) event.ignore() def dragMoveEvent(self, event): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): file_path = url.toLocalFile().lower() if url.isLocalFile() and ( file_path.endswith(".txt") or file_path.endswith((".srt", ".ass", ".vtt")) ): event.acceptProposedAction() return event.ignore() def dragLeaveEvent(self, event): self.drag_overlay.setVisible(False) event.accept() def dropEvent(self, event): self.drag_overlay.setVisible(False) if event.mimeData().hasUrls(): file_paths = [ url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile() and ( url.toLocalFile().lower().endswith(".txt") or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")) ) ] if file_paths: self.parent_dialog.add_files_from_paths(file_paths) event.acceptProposedAction() else: event.ignore() else: event.ignore() def resizeEvent(self, event): super().resizeEvent(event) if hasattr(self, "drag_overlay"): self.drag_overlay.resize(self.size()) class QueueManager(QDialog): def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): super().__init__() self.queue = queue self._original_queue = deepcopy( queue ) # Store a deep copy of the original queue self.parent = parent layout = QVBoxLayout() layout.setContentsMargins(15, 15, 15, 15) # set main layout margins layout.setSpacing(12) # set spacing between widgets in main layout # list of queued items self.listwidget = DroppableQueueListWidget(self) self.listwidget.setSelectionMode( QAbstractItemView.SelectionMode.ExtendedSelection ) self.listwidget.setAlternatingRowColors(True) self.listwidget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.listwidget.customContextMenuRequested.connect(self.show_context_menu) # Add informative instructions at the top instructions = QLabel( "

How Queue Works?

" "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the 'Add files' button below. " "To add PDF, EPUB or markdown files, use the input box in the main window and click the 'Add to Queue' button. " "Each file in the queue keeps the configuration settings active when it was added. " "Changing the main window configuration afterward does not affect files already in the queue. " "You can view each file's configuration by hovering over them." ) instructions.setAlignment(Qt.AlignmentFlag.AlignLeft) instructions.setWordWrap(True) instructions.setStyleSheet("margin-bottom: 8px;") layout.addWidget(instructions) # Overlay label for empty queue self.empty_overlay = QLabel( "Drag and drop your text or subtitle files here or use the 'Add files' button.", self.listwidget, ) self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) self.empty_overlay.setStyleSheet( f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" ) self.empty_overlay.setWordWrap(True) self.empty_overlay.setAttribute( Qt.WidgetAttribute.WA_TransparentForMouseEvents, True ) self.empty_overlay.hide() # add queue items to the list self.process_queue() button_row = QHBoxLayout() button_row.setContentsMargins(0, 0, 0, 0) # optional: no margins for button row button_row.setSpacing(7) # set spacing between buttons # Add files button add_files_button = QPushButton("Add files") add_files_button.setFixedHeight(40) add_files_button.clicked.connect(self.add_more_files) button_row.addWidget(add_files_button) # Remove button self.remove_button = QPushButton("Remove selected") self.remove_button.setFixedHeight(40) self.remove_button.clicked.connect(self.remove_item) button_row.addWidget(self.remove_button) # Clear button self.clear_button = QPushButton("Clear Queue") self.clear_button.setFixedHeight(40) self.clear_button.clicked.connect(self.clear_queue) button_row.addWidget(self.clear_button) layout.addLayout(button_row) layout.addWidget(self.listwidget) # Connect selection change to update button state self.listwidget.currentItemChanged.connect(self.update_button_states) self.listwidget.itemSelectionChanged.connect(self.update_button_states) buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self, ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self.setLayout(layout) self.setWindowTitle(title) self.resize(*size) self.update_button_states() def process_queue(self): """Process the queue items.""" import os self.listwidget.clear() if not self.queue: self.empty_overlay.show() self.update_button_states() return else: self.empty_overlay.hide() icon_provider = QFileIconProvider() for item in self.queue: # Determine display file path (prefer save_base_path for original file) display_file_path = getattr(item, "save_base_path", None) or item.file_name processing_file_path = item.file_name # Normalize paths for consistent display (fixes Windows path separator issues) display_file_path = ( os.path.normpath(display_file_path) if display_file_path else display_file_path ) processing_file_path = ( os.path.normpath(processing_file_path) if processing_file_path else processing_file_path ) # Only show the file name, not the full path display_name = display_file_path if os.path.sep in display_file_path: display_name = os.path.basename(display_file_path) # Get icon for the display file icon = icon_provider.icon(QFileInfo(display_file_path)) list_item = QListWidgetItem() # Set tooltip with detailed info output_folder = getattr(item, "output_folder", "") # For plain .txt inputs we don't need to show a separate processing file show_processing = True try: if isinstance( display_file_path, str ) and display_file_path.lower().endswith(".txt"): show_processing = False except Exception: show_processing = True tooltip = f"Input File: {display_file_path}
" if ( show_processing and processing_file_path and processing_file_path != display_file_path ): tooltip += f"Processing File: {processing_file_path}
" tooltip += ( f"Language: {getattr(item, 'lang_code', '')}
" f"Speed: {getattr(item, 'speed', '')}
" f"Voice: {getattr(item, 'voice', '')}
" f"Save Option: {getattr(item, 'save_option', '')}
" ) if output_folder not in (None, "", "None"): tooltip += f"Output Folder: {output_folder}
" tooltip += ( f"Subtitle Mode: {getattr(item, 'subtitle_mode', '')}
" f"Output Format: {getattr(item, 'output_format', '')}
" f"Characters: {getattr(item, 'total_char_count', '')}
" f"Replace Single Newlines: {getattr(item, 'replace_single_newlines', False)}
" f"Use Silent Gaps: {getattr(item, 'use_silent_gaps', False)}
" f"Speed Method: {getattr(item, 'subtitle_speed_method', 'tts')}" ) # Add book handler options if present save_chapters_separately = getattr(item, "save_chapters_separately", None) merge_chapters_at_end = getattr(item, "merge_chapters_at_end", None) if save_chapters_separately is not None: tooltip += f"
Save chapters separately: {'Yes' if save_chapters_separately else 'No'}" # Only show merge option if saving chapters separately if save_chapters_separately and merge_chapters_at_end is not None: tooltip += f"
Merge chapters at the end: {'Yes' if merge_chapters_at_end else 'No'}" list_item.setToolTip(tooltip) list_item.setIcon(icon) # Store both paths for context menu list_item.setData( Qt.ItemDataRole.UserRole, { "display_path": display_file_path, "processing_path": processing_file_path, }, ) # Use custom widget for display char_count = getattr(item, "total_char_count", 0) widget = QueueListItemWidget(display_file_path, char_count) self.listwidget.addItem(list_item) self.listwidget.setItemWidget(list_item, widget) self.update_button_states() def remove_item(self): items = self.listwidget.selectedItems() if not items: return from PyQt6.QtWidgets import QMessageBox # Remove by index to ensure correct mapping rows = sorted([self.listwidget.row(item) for item in items], reverse=True) # Warn user if removing multiple files if len(rows) > 1: reply = QMessageBox.question( self, "Confirm Remove", f"Are you sure you want to remove {len(rows)} selected items from the queue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return for row in rows: if 0 <= row < len(self.queue): del self.queue[row] self.process_queue() self.update_button_states() def clear_queue(self): from PyQt6.QtWidgets import QMessageBox if len(self.queue) > 1: reply = QMessageBox.question( self, "Confirm Clear Queue", f"Are you sure you want to clear {len(self.queue)} items from the queue?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return self.queue.clear() self.listwidget.clear() self.empty_overlay.resize( self.listwidget.size() ) # Ensure overlay is sized correctly self.empty_overlay.show() # Show the overlay when queue is empty self.update_button_states() def get_queue(self): return self.queue def get_current_attributes(self): # Fetch current attribute values from the parent abogen GUI attrs = {} parent = self.parent if parent is not None: # lang_code: use parent's get_voice_formula and get_selected_lang if hasattr(parent, "get_voice_formula") and hasattr( parent, "get_selected_lang" ): voice_formula = parent.get_voice_formula() attrs["lang_code"] = parent.get_selected_lang(voice_formula) attrs["voice"] = voice_formula else: attrs["lang_code"] = getattr(parent, "selected_lang", "") attrs["voice"] = getattr(parent, "selected_voice", "") # speed if hasattr(parent, "speed_slider"): attrs["speed"] = parent.speed_slider.value() / 100.0 else: attrs["speed"] = getattr(parent, "speed", 1.0) # save_option attrs["save_option"] = getattr(parent, "save_option", "") # output_folder attrs["output_folder"] = getattr(parent, "selected_output_folder", "") # subtitle_mode if hasattr(parent, "get_actual_subtitle_mode"): attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() else: attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") # output_format attrs["output_format"] = getattr(parent, "selected_format", "") # total_char_count attrs["total_char_count"] = getattr(parent, "char_count", "") # replace_single_newlines attrs["replace_single_newlines"] = getattr( parent, "replace_single_newlines", False ) # use_silent_gaps attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False) # subtitle_speed_method attrs["subtitle_speed_method"] = getattr( parent, "subtitle_speed_method", "tts" ) # book handler options attrs["save_chapters_separately"] = getattr( parent, "save_chapters_separately", None ) attrs["merge_chapters_at_end"] = getattr( parent, "merge_chapters_at_end", None ) else: # fallback: empty values attrs = { k: "" for k in [ "lang_code", "speed", "voice", "save_option", "output_folder", "subtitle_mode", "output_format", "total_char_count", "replace_single_newlines", ] } attrs["save_chapters_separately"] = None attrs["merge_chapters_at_end"] = None return attrs def add_files_from_paths(self, file_paths): from abogen.utils import calculate_text_length from PyQt6.QtWidgets import QMessageBox import os current_attrs = self.get_current_attributes() duplicates = [] for file_path in file_paths: class QueueItem: pass item = QueueItem() item.file_name = file_path item.save_base_path = ( file_path # For .txt files, processing and save paths are the same ) for attr, value in current_attrs.items(): setattr(item, attr, value) # Override subtitle_mode to "Disabled" for subtitle files if file_path.lower().endswith((".srt", ".ass", ".vtt")): item.subtitle_mode = "Disabled" # Read file content and calculate total_char_count using calculate_text_length try: with open(file_path, "r", encoding="utf-8", errors="ignore") as f: file_content = f.read() item.total_char_count = calculate_text_length(file_content) except Exception: item.total_char_count = 0 # Prevent adding duplicate items to the queue (check all attributes) is_duplicate = False for queued_item in self.queue: if ( getattr(queued_item, "file_name", None) == getattr(item, "file_name", None) and getattr(queued_item, "lang_code", None) == getattr(item, "lang_code", None) and getattr(queued_item, "speed", None) == getattr(item, "speed", None) and getattr(queued_item, "voice", None) == getattr(item, "voice", None) and getattr(queued_item, "save_option", None) == getattr(item, "save_option", None) and getattr(queued_item, "output_folder", None) == getattr(item, "output_folder", None) and getattr(queued_item, "subtitle_mode", None) == getattr(item, "subtitle_mode", None) and getattr(queued_item, "output_format", None) == getattr(item, "output_format", None) and getattr(queued_item, "total_char_count", None) == getattr(item, "total_char_count", None) and getattr(queued_item, "replace_single_newlines", False) == getattr(item, "replace_single_newlines", False) and getattr(queued_item, "use_silent_gaps", False) == getattr(item, "use_silent_gaps", False) and getattr(queued_item, "subtitle_speed_method", "tts") == getattr(item, "subtitle_speed_method", "tts") and getattr(queued_item, "save_base_path", None) == getattr(item, "save_base_path", None) and getattr(queued_item, "save_chapters_separately", None) == getattr(item, "save_chapters_separately", None) and getattr(queued_item, "merge_chapters_at_end", None) == getattr(item, "merge_chapters_at_end", None) ): is_duplicate = True break if is_duplicate: duplicates.append(os.path.basename(file_path)) continue self.queue.append(item) if duplicates: QMessageBox.warning( self, "Duplicate Item(s)", f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", ) self.process_queue() self.update_button_states() def add_more_files(self): from PyQt6.QtWidgets import QFileDialog from abogen.utils import calculate_text_length # import the function # Allow .txt, .srt, .ass, and .vtt files files, _ = QFileDialog.getOpenFileNames( self, "Select text or subtitle files", "", "Supported Files (*.txt *.srt *.ass *.vtt)", ) if not files: return self.add_files_from_paths(files) def resizeEvent(self, event): super().resizeEvent(event) if hasattr(self, "empty_overlay"): self.empty_overlay.resize(self.listwidget.size()) def update_button_states(self): # Enable Remove if at least one item is selected, else disable if hasattr(self, "remove_button"): selected_count = len(self.listwidget.selectedItems()) self.remove_button.setEnabled(selected_count > 0) if selected_count > 1: self.remove_button.setText(f"Remove selected ({selected_count})") else: self.remove_button.setText("Remove selected") # Disable Clear if queue is empty if hasattr(self, "clear_button"): self.clear_button.setEnabled(bool(self.queue)) def show_context_menu(self, pos): from PyQt6.QtWidgets import QMenu from PyQt6.QtGui import QAction, QDesktopServices from PyQt6.QtCore import QUrl import os global_pos = self.listwidget.viewport().mapToGlobal(pos) selected_items = self.listwidget.selectedItems() menu = QMenu(self) if len(selected_items) == 1: # Add Remove action remove_action = QAction("Remove this item", self) remove_action.triggered.connect(self.remove_item) menu.addAction(remove_action) # Get paths for determining if it's a document input item = selected_items[0] paths = item.data(Qt.ItemDataRole.UserRole) if isinstance(paths, dict): display_path = paths.get("display_path", "") processing_path = paths.get("processing_path", "") else: display_path = paths processing_path = paths doc_exts = (".md", ".markdown", ".pdf", ".epub") is_document_input = ( isinstance(display_path, str) and display_path.lower().endswith(doc_exts) ) or ( isinstance(processing_path, str) and processing_path.lower().endswith(doc_exts) ) # Add Open file action(s) def open_file_by_path(path_label: str): from PyQt6.QtWidgets import QMessageBox p = display_path if path_label == "display" else processing_path if not p: QMessageBox.warning( self, "File Not Found", "Path is not available." ) return # Find the queue item and resolve the target path target_path = None for q in self.queue: if ( getattr(q, "save_base_path", None) == display_path or q.file_name == display_path ): if path_label == "display": target_path = ( getattr(q, "save_base_path", None) or q.file_name ) else: target_path = q.file_name break if ( getattr(q, "save_base_path", None) == processing_path or q.file_name == processing_path ): if path_label == "display": target_path = ( getattr(q, "save_base_path", None) or q.file_name ) else: target_path = q.file_name break # Fallback to the raw path if resolution failed if not target_path: target_path = p if not os.path.exists(target_path): QMessageBox.warning( self, "File Not Found", f"The file does not exist." ) return QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) if is_document_input: # For documents, show two open options open_processed_action = QAction("Open processed file", self) open_processed_action.triggered.connect( lambda: open_file_by_path("processing") ) menu.addAction(open_processed_action) open_input_action = QAction("Open input file", self) open_input_action.triggered.connect( lambda: open_file_by_path("display") ) menu.addAction(open_input_action) else: # For plain text files, show single open option open_file_action = QAction("Open file", self) open_file_action.triggered.connect(lambda: open_file_by_path("display")) menu.addAction(open_file_action) # Add Go to folder action # If the queued item represents a converted document (markdown, pdf, epub) # show two actions: Go to processed file (the cached .txt) and Go to input file (original source) from PyQt6.QtWidgets import QMessageBox def open_folder_for(path_label: str): # path_label should be either 'display' or 'processing' p = display_path if path_label == "display" else processing_path if not p: QMessageBox.warning( self, "File Not Found", "Path is not available." ) return # If the stored path is the display path (original) but the actual file may be # stored on the queue object differently, try to resolve via the queue entry. target_path = None for q in self.queue: if ( getattr(q, "save_base_path", None) == display_path or q.file_name == display_path ): if path_label == "display": target_path = ( getattr(q, "save_base_path", None) or q.file_name ) else: target_path = q.file_name break if ( getattr(q, "save_base_path", None) == processing_path or q.file_name == processing_path ): if path_label == "display": target_path = ( getattr(q, "save_base_path", None) or q.file_name ) else: target_path = q.file_name break # Fallback to the raw path if resolution failed if not target_path: target_path = p if not os.path.exists(target_path): QMessageBox.warning( self, "File Not Found", f"The file does not exist: {target_path}", ) return folder = os.path.dirname(target_path) if os.path.exists(folder): QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) if is_document_input: processed_action = QAction("Go to processed file", self) processed_action.triggered.connect( lambda: open_folder_for("processing") ) menu.addAction(processed_action) input_action = QAction("Go to input file", self) input_action.triggered.connect(lambda: open_folder_for("display")) menu.addAction(input_action) else: # Default behavior for non-document inputs: single "Go to folder" action go_to_folder_action = QAction("Go to folder", self) def go_to_folder(): item = selected_items[0] paths = item.data(Qt.ItemDataRole.UserRole) if isinstance(paths, dict): file_path = paths.get( "display_path", paths.get("processing_path", "") ) else: file_path = paths # Fallback for old format # Find the queue item for q in self.queue: if ( getattr(q, "save_base_path", None) == file_path or q.file_name == file_path ): target_path = ( getattr(q, "save_base_path", None) or q.file_name ) if not os.path.exists(target_path): QMessageBox.warning( self, "File Not Found", f"The file does not exist." ) return folder = os.path.dirname(target_path) if os.path.exists(folder): QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) break go_to_folder_action.triggered.connect(go_to_folder) menu.addAction(go_to_folder_action) elif len(selected_items) > 1: remove_action = QAction(f"Remove selected ({len(selected_items)})", self) remove_action.triggered.connect(self.remove_item) menu.addAction(remove_action) # Always add Clear Queue clear_action = QAction("Clear Queue", self) clear_action.triggered.connect(self.clear_queue) menu.addAction(clear_action) menu.exec(global_pos) def accept(self): # Accept: keep changes super().accept() def reject(self): # Cancel: restore original queue from PyQt6.QtWidgets import QMessageBox # Warn if user changed a lot (e.g., more than 1 items difference) original_count = len(self._original_queue) current_count = len(self.queue) if abs(original_count - current_count) > 1: reply = QMessageBox.question( self, "Confirm Cancel", f"Are you sure you want to cancel and discard all changes?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return self.queue.clear() self.queue.extend(deepcopy(self._original_queue)) super().reject() def keyPressEvent(self, event): from PyQt6.QtCore import Qt if event.key() == Qt.Key.Key_Delete: self.remove_item() else: super().keyPressEvent(event)