diff --git a/UI/AppStartup.py b/UI/AppStartup.py new file mode 100644 index 0000000..49c8745 --- /dev/null +++ b/UI/AppStartup.py @@ -0,0 +1,127 @@ +from PySide6.QtWidgets import QApplication, QMessageBox +from PySide6.QtCore import QThread, Signal, QObject +import os, time + +from UI.SplashScreen import SplashScreen +from UI.MainWindow import MainWindow +from utils.Logger import logger +from utils.CheckInternet import Internet + + +# STARTUP WORKER THREAD +class StartupWorker(QThread): + status_updated = Signal(str, int) # message, progress % + finished = Signal(bool) + step_timing = {} + + def __init__(self, parent=None): + super().__init__(parent) + + def run(self): + self.step("Checking internet connection...", 10) + internet = Internet() + connected = internet.check_internet() + + for _ in range(2): + if connected: + break + time.sleep(1) + connected = internet.check_internet() + + if not connected: + self.finished.emit(False) + return + + self.step("Initializing plugins...", 25) + time.sleep(0.5) + + self.step("Loading UI modules...", 45) + time.sleep(0.5) + + self.step("Preparing database engine...", 65) + time.sleep(0.5) + + self.step("Optimizing startup cache...", 80) + time.sleep(0.5) + + self.step("Finalizing system checks...", 95) + time.sleep(0.4) + + self.step("Startup ready", 100) + self.finished.emit(True) + + def step(self, msg, progress): + logger.info(msg) + self.status_updated.emit(msg, progress) + self.step_timing[msg] = time.time() + + +# APP STARTUP CONTROLLER +class AppStartup(QObject): + def __init__(self): + super().__init__() + + base_dir = os.path.dirname(os.path.abspath(__file__)) + self.base_dir = os.path.dirname(base_dir) + gif_path = os.path.join(self.base_dir, "assets", "splash", "loading.gif") + + # parent=None to avoid QDialog parent type error + self.splash = SplashScreen(parent=None, gif_path=gif_path) + self.splash.set_title("StaTube - YouTube Data Analysis Tool") + self.splash.update_status("Booting system...") + self.splash.set_progress(5) + + # Use animated show + self.splash.show_with_animation() + + logger.info("Splash screen shown with fade-in animation.") + + self.start_worker() + + # START BACKGROUND TASK + def start_worker(self): + self.worker = StartupWorker() + self.worker.status_updated.connect(self.on_status_update) + self.worker.finished.connect(self.on_finished) + self.worker.start() + + def on_status_update(self, message: str, progress: int): + self.splash.update_status(message) + self.splash.set_progress(progress) + + # FINAL HANDOFF + def on_finished(self, connected: bool): + self.splash.fade_and_close() + if not connected: + msg = QMessageBox() + msg.setIcon(QMessageBox.Warning) + msg.setWindowTitle("Connection Issue") + msg.setText( + "No internet connection detected.\n\n" + "StaTube can continue in offline mode, but some features may not work." + ) + continue_btn = msg.addButton("Continue Offline", QMessageBox.AcceptRole) + quit_btn = msg.addButton("Quit", QMessageBox.RejectRole) + msg.setDefaultButton(continue_btn) + msg.exec() + + if msg.clickedButton() == quit_btn: + QApplication.instance().quit() + return + + # NOW SAFE TO CREATE MAIN WINDOW + logger.info("Launching MainWindow after verified startup.") + try: + self.main_window = MainWindow() + self.main_window.finish_initialization() + self.main_window.showMaximized() + except Exception as e: + logger.exception("Fatal UI startup failure") + QMessageBox.critical( + None, + "Startup Failure", + "StaTube failed to initialize UI. Starting in Safe Mode." + ) + logger.info("===== Startup Timing Report =====") + for step, t in self.worker.step_timing.items(): + logger.info(f"{step}") diff --git a/UI/Homepage.py b/UI/Homepage.py index 1a25e1c..2164f10 100644 --- a/UI/Homepage.py +++ b/UI/Homepage.py @@ -207,18 +207,27 @@ def on_item_selected(self, item: QListWidgetItem) -> None: @QtCore.Slot() def show_search_splash(self) -> None: - """ - Show splash screen for search operation. - - Creates and displays a splash screen with an animated loading GIF - to provide visual feedback during channel search operations. - """ cwd = os.getcwd() gif_path = os.path.join(cwd, "assets", "gif", "loading.gif") + + if self.splash: + self.splash.close() + self.splash = SplashScreen(parent=self.mainwindow, gif_path=gif_path) self.splash.set_title("Searching Channels...") self.splash.update_status("Fetching channel information...") - self.splash.show() + self.splash.set_progress(0) + + # Runtime overlay + cancel support + self.splash.enable_runtime_mode( + parent_window=self.mainwindow, + cancel_callback=self.cancel_search + ) + + # THIS IS REQUIRED + self.splash.show_with_animation() + self.splash.raise_() + self.splash.activateWindow() @QtCore.Slot(int, str) def on_progress_update(self, progress: int, status: str) -> None: @@ -240,15 +249,20 @@ def on_progress_update(self, progress: int, status: str) -> None: @QtCore.Slot() def close_splash(self) -> None: - """ - Close splash screen. - - Closes and cleans up the splash screen instance if it exists. - """ if self.splash: - self.splash.close() + self.splash.fade_and_close(400) self.splash = None + def cancel_search(self): + logger.warning("User cancelled search operation.") + + self.stop_event.set() + + if self.search_thread_instance and self.search_thread_instance.is_alive(): + self.search_thread_instance.join(timeout=0.5) + + self.close_splash_signal.emit() + def update_results(self, channels: List[str]) -> None: """ Update dropdown list with search results. @@ -382,27 +396,25 @@ def _run_search(self, query: str, final: bool) -> None: # Check if thread should stop before starting work if self.stop_event.is_set(): + self.close_splash_signal.emit() logger.debug("Search thread cancelled before execution") return try: - if final: - # Show splash screen for final search - self.show_splash_signal.emit() - + if final: # Define progress callback for the search - def progress_callback(progress: Any, status: Optional[str] = None) -> None: - """ - Callback function for reporting search progress. - - Args: - progress: Progress value (int/float for percentage, str for status message) - status: Optional status message, defaults to None - """ + def progress_callback(progress: Any, status: Optional[str] = None): + if self.stop_event.is_set(): + return + if isinstance(progress, (int, float)): self.progress_update.emit(int(progress), status or "") + if self.splash: + self.splash.update_eta(int(progress)) + elif isinstance(progress, str): - self.progress_update.emit(-1, progress) # -1 means don't update progress bar + self.progress_update.emit(-1, progress) + # Perform search with progress tracking self.channels = self.search.search_channel( @@ -444,26 +456,33 @@ def progress_callback(progress: Any, status: Optional[str] = None) -> None: # Signal that search is complete self.search_complete.emit() + if self.stop_event.is_set(): + self.close_splash_signal.emit() + return + def search_channel(self) -> None: """ Handle search button click event. - - Initiates a final comprehensive search for the query entered in the search bar. - Cancels any ongoing auto-search operations before starting the new search. - Clears previous incomplete results to ensure fresh data is retrieved. """ query = self.searchbar.text().strip() if not query: return - # --- Cancel any ongoing auto-search --- + # CANCEL any running search if self.search_thread_instance and self.search_thread_instance.is_alive(): self.stop_event.set() - self.search_thread_instance.join(timeout=1.0) # Ensure it fully stops before continuing + self.search_thread_instance.join(timeout=0.5) - # Clear old incomplete results self.channels = None - self.search.search_channel(name=None) # reset internal state if needed - - # --- Now run the final search with fresh data --- - self.search_keyword(query=query, final=True) \ No newline at end of file + self.stop_event.clear() + + # SHOW SPLASH IMMEDIATELY (MAIN THREAD) + self.show_search_splash() + + # START WORKER THREAD AFTER SPLASH IS VISIBLE + self.search_thread_instance = threading.Thread( + target=self._run_search, + daemon=True, + args=(query, True) + ) + self.search_thread_instance.start() diff --git a/UI/MainWindow.py b/UI/MainWindow.py index 60653dd..8c633a1 100644 --- a/UI/MainWindow.py +++ b/UI/MainWindow.py @@ -4,10 +4,10 @@ from PySide6.QtWidgets import ( QApplication, QMainWindow, QStackedWidget, QFrame, QWidget, - QVBoxLayout, QHBoxLayout, QToolButton, QMessageBox + QVBoxLayout, QHBoxLayout, QToolButton ) from PySide6.QtGui import QIcon -from PySide6.QtCore import Qt, QSize, QThread, Signal +from PySide6.QtCore import Qt, QSize, QTimer # ---- Import Pages ---- from .Homepage import Home @@ -18,53 +18,12 @@ from .SplashScreen import SplashScreen from Data.DatabaseManager import DatabaseManager -from utils.CheckInternet import Internet from utils.Logger import logger # ---- Import AppState ---- from utils.AppState import app_state -class StartupWorker(QThread): - """ - Background worker for startup tasks such as internet checks. - Keeps UI responsive while doing blocking work. - """ - status_updated = Signal(str) - finished = Signal(bool) # True = internet OK, False = still offline after retries - - def __init__(self, parent=None, max_retries: int = 3, retry_delay: float = 2.0): - super().__init__(parent) - self.max_retries = max_retries - self.retry_delay = retry_delay - - def run(self) -> None: - internet = Internet() - connected = False - - for attempt in range(self.max_retries): - # R2: general “soft” messages, not attempt counts - if attempt == 0: - self.status_updated.emit("Checking internet connection...") - elif attempt == 1: - self.status_updated.emit("Still checking your connection...") - else: - self.status_updated.emit("Almost there, verifying network...") - - logger.debug(f"StartupWorker: Checking internet (attempt {attempt + 1}/{self.max_retries})") - connected = internet.check_internet() - logger.debug(f"StartupWorker: Internet check result: {connected}") - - if connected: - logger.info(f"StartupWorker finished. Internet connected: {connected}") - break - - # Small delay before retrying (background thread, so safe) - time.sleep(self.retry_delay) - - self.finished.emit(bool(connected)) - - class MainWindow(QMainWindow): """ Main window of the application. @@ -80,10 +39,10 @@ def __init__(self): base_dir = os.path.dirname(os.path.abspath(__file__)) self.base_dir = os.path.dirname(base_dir) icon_path = os.path.join(self.base_dir, "icon", "StaTube.ico") + gif_path = os.path.join(self.base_dir, "assets", "splash", "loading.gif") logger.debug(f"Resolved application base directory: {self.base_dir}") logger.debug(f"Using icon path: {icon_path}") - self.setWindowTitle("StaTube - YouTube Data Analysis Tool") self.setWindowIcon(QIcon(icon_path)) # Initial geometry (window can be resized later) @@ -95,95 +54,29 @@ def __init__(self): # Create stacked widget (pages will be added later in setup_ui) self.stack = QStackedWidget() + self.splash = SplashScreen(parent=self, gif_path=gif_path) # Sidebar button list self.sidebar_buttons = [] - # Splash screen - gif_path = os.path.join(self.base_dir, "assets", "splash", "loading.gif") - self.splash = SplashScreen(parent=self, gif_path=gif_path) - # self.splash = SplashScreen(parent=self) - self.splash.set_title("StaTube - YouTube Data Analysis Tool") - self.splash.update_status("Starting application...") - logger.info("Displaying splash screen and starting asynchronous startup sequence.") - self.splash.show() - - # Start asynchronous startup flow - self.start_startup_sequence() - - # ---------- Startup Sequence ---------- - - def start_startup_sequence(self): - """ - Kick off background startup tasks (internet checks, etc.) - while showing the splash screen. - """ - logger.debug("StartupWorker thread created. Beginning internet check process...") - self.startup_worker = StartupWorker(self, max_retries=3, retry_delay=2.0) - self.startup_worker.status_updated.connect(self.splash.update_status) - self.startup_worker.finished.connect(self.on_startup_finished) - self.startup_worker.start() - - def on_startup_finished(self, connected: bool): - """ - Called when the startup worker finishes internet checks. - """ - logger.info(f"Startup network check completed. Connected = {connected}") - if not connected: - # Show dialog: Continue Offline / Quit - self.splash.close() - logger.warning("No internet detected. User will be prompted for offline mode or exit.") - msg = QMessageBox(self) - msg.setIcon(QMessageBox.Warning) - msg.setWindowTitle("Connection Issue") - msg.setText( - "No internet connection detected.\n\n" - "StaTube can continue in offline mode, but some features may not work.\n" - "What would you like to do?" - ) - continue_btn = msg.addButton("Continue Offline", QMessageBox.AcceptRole) - quit_btn = msg.addButton("Quit", QMessageBox.RejectRole) - msg.setDefaultButton(continue_btn) - - msg.exec() - - if msg.clickedButton() == quit_btn: - # User chose to quit; close the app - QApplication.instance().quit() - return - - # If user chose to continue offline, just carry on to setup - self.splash.update_status("Continuing in offline mode...") - - else: - logger.info("Internet connection verified. Proceeding with initialization.") - self.splash.update_status("Internet connection established. Preparing application...") - - # Now perform remaining init (DB, stylesheet, pages) - self.finish_initialization() - def finish_initialization(self): - """ - Perform remaining initialization once startup checks are done. - """ - logger.info("Starting final initialization sequence (DB, UI, stylesheet).") - # Load stylesheet + logger.info("Starting final initialization sequence.") + + self.splash.update_status("Loading theme...") self.load_stylesheet() + self.splash.set_progress(40) - # Initialize database and store in app_state - db: DatabaseManager = DatabaseManager() - logger.debug("DatabaseManager instance created and stored in app_state.") + self.splash.update_status("Connecting database...") + db = DatabaseManager() app_state.db = db + self.splash.set_progress(70) - # Setup the full UI now + self.splash.update_status("Building UI layout...") self.setup_ui() - logger.info("UI setup completed. Finalizing splash screen fade-out.") + self.splash.set_progress(95) - # Smooth fade-out of the splash, then show main window fully ready - self.splash.fade_and_close(duration_ms=700) + self.splash.update_status("Startup complete") - # Debug log - logger.debug("Main UI initialized successfully") # ---------- Stylesheet ---------- @@ -311,7 +204,8 @@ def switch_and_scrape_video(self, scrape_shorts: bool = False): self.sidebar_buttons[0].setChecked(False) self.sidebar_buttons[1].setChecked(True) self.switch_page(1) - self.video_page.video_page_scrape_video_signal.emit(scrape_shorts) + # Add a small delay to ensure page switch completes before showing splash + QTimer.singleShot(50, lambda: self.video_page.video_page_scrape_video_signal.emit(scrape_shorts)) def switch_and_scrape_transcripts(self): """ diff --git a/UI/SplashScreen.py b/UI/SplashScreen.py index 44d1666..9466ee7 100644 --- a/UI/SplashScreen.py +++ b/UI/SplashScreen.py @@ -1,58 +1,106 @@ -from PySide6.QtWidgets import QDialog, QProgressBar, QLabel, QVBoxLayout -from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve, Property, QEvent -from PySide6.QtGui import QPixmap, QFont, QPainter, QMovie, QColor, QPen, QLinearGradient, QGuiApplication +from PySide6.QtWidgets import ( + QDialog, QProgressBar, QLabel, QVBoxLayout, + QWidget, QPushButton +) +from PySide6.QtCore import ( + Qt, QPropertyAnimation, QEasingCurve, + Property, QEvent +) +from PySide6.QtGui import ( + QFont, QPainter, QMovie, QColor, + QPen, QLinearGradient, QGuiApplication, QPalette +) +import time + + +class BlurOverlay(QWidget): + """ + Semi-transparent overlay that dims only the main window. + It is a child of the main window, so it follows its position, + stacking, and minimize/restore behavior. + """ + def __init__(self, parent_window: QWidget): + super().__init__(parent_window) + + # Child, no separate taskbar entry + self.setWindowFlags(Qt.Widget | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + self.setAttribute(Qt.WA_ShowWithoutActivating, True) + self.setAttribute(Qt.WA_TransparentForMouseEvents, False) + + self.setWindowOpacity(0.35) + + if parent_window: + self.setGeometry(parent_window.rect()) + + self.show() + + def paintEvent(self, event): + painter = QPainter(self) + painter.fillRect(self.rect(), QColor(0, 0, 0, 160)) class SplashScreen(QDialog): """ - A frameless dialog that shows a GIF/Animation and status messages during startup. + Runtime / startup splash dialog. + + - Stays above the main window + - Goes behind other applications + - Minimizes/restores with the main window + - Optional overlay dim & cancel button """ - def __init__(self, parent=None, gif_path=None): - """ - Initializes the SplashScreen widget. - Args: - parent (QWidget): The parent widget (optional). - gif_path (str): The path to the GIF to display. - """ + def __init__(self, parent: QWidget | None = None, gif_path: str | None = None): super().__init__(parent) - # Window flags: frameless, always on top, no taskbar button + self.overlay: BlurOverlay | None = None + self.cancel_button: QPushButton | None = None + self.eta_label: QLabel | None = None + self.start_time: float | None = None + + # Install event filter on parent to track minimize/restore/move/resize + if parent is not None: + parent.installEventFilter(self) + + # IMPORTANT: No global always-on-top. Tool + Frameless keeps it tied to app. self.setWindowFlags( - Qt.Dialog - | Qt.FramelessWindowHint - | Qt.WindowStaysOnTopHint + Qt.Tool | # no taskbar button, stays with parent + Qt.FramelessWindowHint # borderless ) self.setAttribute(Qt.WA_TranslucentBackground) - self.setModal(True) # Block interaction with parent while visible + self.setModal(False) - # Fixed size + # Size and state self.setFixedSize(550, 450) - - # Centering flag self._centered_once = False - self.title = "" self.status = "" - self._opacity = 1.0 + self._opacity = 0.0 + self.setWindowOpacity(0.0) + + # Theme + self._is_dark_theme = self._detect_color_scheme() + self._setup_theme_palette() # === Layout === layout = QVBoxLayout(self) layout.setContentsMargins(30, 30, 30, 30) layout.setSpacing(20) - # Title label + # Title self.title_label = QLabel("") self.title_label.setAlignment(Qt.AlignCenter) self.title_label.setFont(QFont("Segoe UI", 16, QFont.Bold)) - self.title_label.setStyleSheet("color: #ffffff; padding: 10px;") + self.title_label.setStyleSheet( + f"color: {self._title_color.name()}; padding: 10px;" + ) layout.addWidget(self.title_label) - # GIF / animation area + # GIF self.movie_label = QLabel(self) self.movie_label.setAlignment(Qt.AlignCenter) self.movie_label.setFixedSize(200, 200) - self.movie = None + self.movie: QMovie | None = None if gif_path: self.movie = QMovie(gif_path) @@ -60,14 +108,9 @@ def __init__(self, parent=None, gif_path=None): self.movie_label.setMovie(self.movie) self.movie.start() else: - self.movie_label.setText("●●●") - self.movie_label.setFont(QFont("Segoe UI", 48)) - self.movie_label.setStyleSheet("color: #64b5f6;") + self._set_fallback_loader() else: - # Default fallback when no GIF path is provided - self.movie_label.setText("●●●") - self.movie_label.setFont(QFont("Segoe UI", 48)) - self.movie_label.setStyleSheet("color: #64b5f6;") + self._set_fallback_loader() layout.addWidget(self.movie_label, alignment=Qt.AlignCenter) @@ -77,63 +120,158 @@ def __init__(self, parent=None, gif_path=None): self.progress_bar.setValue(0) self.progress_bar.setTextVisible(False) self.progress_bar.setFixedHeight(6) - self.progress_bar.setStyleSheet(""" - QProgressBar { - border: none; - border-radius: 3px; - background-color: rgba(255, 255, 255, 0.1); - } - QProgressBar::chunk { - border-radius: 3px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #1e88e5, stop:0.5 #42a5f5, stop:1 #64b5f6); - } - """) + self._apply_progressbar_style() layout.addWidget(self.progress_bar) - # Status label + # Status text self.status_label = QLabel("") self.status_label.setAlignment(Qt.AlignCenter) self.status_label.setFont(QFont("Segoe UI", 11)) - self.status_label.setStyleSheet("color: #b0bec5; padding: 5px;") + self.status_label.setStyleSheet( + f"color: {self._status_color.name()}; padding: 5px;" + ) self.status_label.setWordWrap(True) layout.addWidget(self.status_label) + # ETA + self.eta_label = QLabel("") + self.eta_label.setAlignment(Qt.AlignCenter) + self.eta_label.setFont(QFont("Segoe UI", 10)) + self.eta_label.setStyleSheet("color: #90caf9;") + layout.addWidget(self.eta_label) + + # Cancel button + self.cancel_button = QPushButton("Cancel") + self.cancel_button.setFixedWidth(120) + self.cancel_button.setVisible(False) + layout.addWidget(self.cancel_button, alignment=Qt.AlignCenter) + layout.addStretch() - # Keep reference to animation so it is not garbage collected - self._fade_animation = None + self._fade_animation: QPropertyAnimation | None = None + self._fade_in_animation: QPropertyAnimation | None = None - # ---------- Positioning / Painting ---------- + # ---------- Event Filter (track parent window) ---------- - def showEvent(self, event: QEvent) -> None: + def eventFilter(self, obj, event): """ - Ensure the splash is centered on the correct screen when shown. + Track the parent window so the splash: + - Minimizes/restores together + - Keeps overlay aligned on move/resize """ + parent = self.parent() + if parent is not None and obj is parent: + et = event.type() + if et == QEvent.WindowStateChange: + if parent.isMinimized(): + self.showMinimized() + if self.overlay: + self.overlay.hide() + else: + # Restored + if self.isMinimized(): + self.showNormal() + self.raise_() + self.activateWindow() + if self.overlay: + self.overlay.show() + elif et in (QEvent.Move, QEvent.Resize): + # Keep overlay covering the parent + if self.overlay and parent is not None: + self.overlay.setGeometry(parent.rect()) + + return super().eventFilter(obj, event) + + # ---------- Theme detection & setup ---------- + + def _detect_color_scheme(self) -> bool: + app = QGuiApplication.instance() + if not app: + return True # assume dark + + palette: QPalette = app.palette() + window_color = palette.color(QPalette.Window) + luminance = ( + 0.299 * window_color.red() + + 0.587 * window_color.green() + + 0.114 * window_color.blue() + ) + return luminance < 128 + + def _setup_theme_palette(self): + if self._is_dark_theme: + self._gradient_top = QColor(26, 35, 39) + self._gradient_mid = QColor(38, 50, 56) + self._gradient_bottom = QColor(55, 71, 79) + + self._border_color = QColor(100, 181, 246, 100) + self._title_color = QColor(255, 255, 255) + self._status_color = QColor(176, 190, 197) + + self._progress_bg = "rgba(255, 255, 255, 0.12)" + self._progress_chunk_start = "#1e88e5" + self._progress_chunk_mid = "#42a5f5" + self._progress_chunk_end = "#64b5f6" + else: + self._gradient_top = QColor(245, 245, 245) + self._gradient_mid = QColor(232, 234, 246) + self._gradient_bottom = QColor(225, 245, 254) + + self._border_color = QColor(120, 144, 156, 160) + self._title_color = QColor(33, 33, 33) + self._status_color = QColor(97, 97, 97) + + self._progress_bg = "rgba(0, 0, 0, 0.06)" + self._progress_chunk_start = "#1e88e5" + self._progress_chunk_mid = "#1976d2" + self._progress_chunk_end = "#0d47a1" + + def _apply_progressbar_style(self): + self.progress_bar.setStyleSheet(f""" + QProgressBar {{ + border: none; + border-radius: 3px; + background-color: {self._progress_bg}; + }} + QProgressBar::chunk {{ + border-radius: 3px; + background: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 {self._progress_chunk_start}, + stop:0.5 {self._progress_chunk_mid}, + stop:1 {self._progress_chunk_end} + ); + }} + """) + + def _set_fallback_loader(self): + self.movie_label.setText("●●●") + self.movie_label.setFont(QFont("Segoe UI", 48)) + accent = QColor(100, 181, 246) if self._is_dark_theme else QColor(25, 118, 210) + self.movie_label.setStyleSheet(f"color: {accent.name()};") + + # ---------- Painting & positioning ---------- + + def showEvent(self, event): super().showEvent(event) if not self._centered_once: self._centered_once = True - # Try to center on the same screen as parent if possible - screen = None - if self.parent() and self.parent().windowHandle(): - screen = self.parent().windowHandle().screen() - - if screen is None: + parent = self.parent() + if parent is not None and parent.isVisible(): + geo = parent.frameGeometry() + else: screen = QGuiApplication.primaryScreen() + geo = screen.availableGeometry() if screen else None - if screen: - geo = screen.availableGeometry() + if geo: self.move( geo.center().x() - self.width() // 2, geo.center().y() - self.height() // 2 ) def paintEvent(self, event): - """ - Draw modern gradient background with border and soft shadow. - """ painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) @@ -144,51 +282,80 @@ def paintEvent(self, event): # Gradient background gradient = QLinearGradient(0, 0, 0, self.height()) - gradient.setColorAt(0, QColor(26, 35, 39)) # #1a2327 - gradient.setColorAt(0.5, QColor(38, 50, 56)) # #263238 - gradient.setColorAt(1, QColor(55, 71, 79)) # #37474f + gradient.setColorAt(0, self._gradient_top) + gradient.setColorAt(0.5, self._gradient_mid) + gradient.setColorAt(1, self._gradient_bottom) painter.setBrush(gradient) painter.drawRoundedRect(0, 0, self.width(), self.height(), 12, 12) # Border - painter.setPen(QPen(QColor(100, 181, 246, 100), 2)) + painter.setPen(QPen(self._border_color, 2)) painter.setBrush(Qt.NoBrush) painter.drawRoundedRect(1, 1, self.width() - 2, self.height() - 2, 12, 12) # ---------- Public API ---------- def set_title(self, title: str): - """ - Set the title of the SplashScreen dialog. - """ self.title = title self.title_label.setText(title) def update_status(self, message: str): - """ - Update the status message of the SplashScreen dialog. - """ self.status = message self.status_label.setText(message) def set_progress(self, value: int): - """ - Set the progress bar value (0-100). - """ self.progress_bar.setValue(int(value)) - def fade_and_close(self, duration_ms: int = 700): + def update_eta(self, progress: int): + if self.start_time is None or progress <= 0: + return + + elapsed = time.time() - self.start_time + total_estimated = elapsed * (100.0 / progress) + remaining = max(0, int(total_estimated - elapsed)) + + mins, secs = divmod(remaining, 60) + self.eta_label.setText( + f"Estimated time remaining: {mins:02d}:{secs:02d}" + ) + + def enable_runtime_mode(self, parent_window: QWidget, cancel_callback): """ - Fade out smoothly and close the splash. + Call this for long-running tasks (search, scraping). """ + if parent_window is not None: + self.overlay = BlurOverlay(parent_window) + self.cancel_button.setVisible(True) + self.cancel_button.clicked.connect(cancel_callback) + + # ---------- Animations ---------- + + def show_with_animation(self, duration_ms: int = 500): + self.start_time = time.time() + self.setWindowOpacity(0.0) + self._opacity = 0.0 + + self.show() + self.raise_() + self.activateWindow() + + self.setWindowState((self.windowState() & ~Qt.WindowMinimized) | Qt.WindowActive) + + self._fade_in_animation = QPropertyAnimation(self, b"opacity", self) + self._fade_in_animation.setDuration(duration_ms) + self._fade_in_animation.setStartValue(0.0) + self._fade_in_animation.setEndValue(1.0) + self._fade_in_animation.setEasingCurve(QEasingCurve.OutCubic) + self._fade_in_animation.start() + + def fade_and_close(self, duration_ms: int = 700): if self._fade_animation is not None: - # Already fading return self._fade_animation = QPropertyAnimation(self, b"opacity", self) self._fade_animation.setDuration(duration_ms) - self._fade_animation.setStartValue(1.0) + self._fade_animation.setStartValue(self.windowOpacity()) self._fade_animation.setEndValue(0.0) self._fade_animation.setEasingCurve(QEasingCurve.InOutQuad) self._fade_animation.finished.connect(self.close) @@ -210,4 +377,9 @@ def set_opacity(self, value: float): def closeEvent(self, event): if self.movie: self.movie.stop() + + if self.overlay: + self.overlay.close() + self.overlay = None + super().closeEvent(event) diff --git a/UI/VideoPage.py b/UI/VideoPage.py index 5e3f44c..d3139ff 100644 --- a/UI/VideoPage.py +++ b/UI/VideoPage.py @@ -588,10 +588,56 @@ def show_splash_screen(self, parent: Optional[QWidget] = None, gif_path: str = " """ cwd = os.getcwd() gif_path = os.path.join(cwd, "assets", "gif", "loading.gif") if not gif_path else gif_path - self.splash = SplashScreen(parent=parent, gif_path=gif_path) + + # Always destroy previous instance cleanly + if self.splash: + self.splash.close() + self.splash = None + + # IMPORTANT: parent MUST be None for runtime dialogs + self.splash = SplashScreen(parent=self.mainwindow, gif_path=gif_path) self.splash.set_title(title) self.splash.update_status("Starting...") - self.splash.show() + self.splash.set_progress(0) + + # Enable overlay + cancel runtime mode + self.splash.enable_runtime_mode( + parent_window=self.mainwindow, + cancel_callback=self.cancel_scraping + ) + + # THIS IS REQUIRED — show() WILL NOT WORK + self.splash.show_with_animation() + + def cancel_scraping(self): + """ + Called when user presses Cancel on splash screen. + Safely stops active workers and closes splash. + """ + logger.warning("User cancelled scraping operation.") + + # --- Stop video scraping --- + if self.worker_thread and self.worker_thread.isRunning(): + self.worker_thread.requestInterruption() + self.worker_thread.quit() + self.worker_thread.wait(500) + + # --- Stop transcript scraping --- + if self.transcript_thread and self.transcript_thread.isRunning(): + self.transcript_thread.requestInterruption() + self.transcript_thread.quit() + self.transcript_thread.wait(500) + + # --- Stop comment scraping --- + if self.comment_thread and self.comment_thread.isRunning(): + self.comment_thread.requestInterruption() + self.comment_thread.quit() + self.comment_thread.wait(500) + + # Fade & cleanup splash safely + if self.splash: + self.splash.fade_and_close(300) + self.splash = None def update_splash_progress(self, message: str) -> None: """ @@ -623,19 +669,20 @@ def on_worker_finished(self) -> None: :return None :rtype: None """ - if self.splash is not None: - self.splash.close() + if self.splash: + self.splash.fade_and_close(400) self.splash = None + logger.info("Video scraping completed!") self.load_videos_from_db() - + def on_transcript_worker_finished(self) -> None: """ Called when the TranscriptWorker thread has finished scraping transcripts. Closes the SplashScreen dialog. """ if self.splash is not None: - self.splash.close() + self.splash.fade_and_close(400) self.splash = None self.video_page_scrape_transcript_signal.emit() @@ -645,7 +692,7 @@ def on_comment_worker_finished(self) -> None: Closes the SplashScreen dialog. """ if self.splash is not None: - self.splash.close() + self.splash.fade_and_close(400) self.splash = None self.video_page_scrape_comments_signal.emit() diff --git a/main.py b/main.py index f0f5aea..d1c2403 100644 --- a/main.py +++ b/main.py @@ -2,10 +2,10 @@ import sys from utils.Logger import logger -from UI.MainWindow import MainWindow +from UI.AppStartup import AppStartup APP_NAME = "StaTube" -APP_VERSION = "0.4.1" +APP_VERSION = "0.4.2" APP_PUBLISHER = "StaTube" APP_DESCRIPTION = "A Python PySide6 GUI app for analyzing YouTube video transcripts and comments." @@ -13,25 +13,20 @@ def main(): logger.info("=== StaTube Boot Process 1: Application starting ===") try: - logger.debug("Initializing QApplication...") - app = QApplication() + app = QApplication(sys.argv) - logger.debug("Creating MainWindow instance...") - window = MainWindow() - - logger.info("MainWindow created successfully. Showing window...") - window.showMaximized() + logger.debug("Launching AppStartup (Splash First)...") + startup = AppStartup() logger.info("Entering Qt event loop...") - app.exec() + sys.exit(app.exec()) except KeyboardInterrupt: logger.warning("Interrupted by user, exiting...") - except Exception as e: + except Exception: logger.exception("Unhandled exception in main()") finally: logger.info("StaTube application shutting down.") - sys.exit(0) if __name__ == "__main__":