diff --git a/CHANGELOG.md b/CHANGELOG.md index cc135e6..7e4de83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ Kept as the work happens, not assembled at release time. Format is with its history. SageTube's own versions start below and are the ones this file records. +## 5.5.1 — 2026-08-09 + +### Fixed + +- **The app could start fullscreen with no way out.** Fullscreen became a state + of the main window in 5.5.0, and the window's geometry is saved on exit — so + quitting from fullscreen brought the app back fullscreen, with no title bar, + no close button, and nothing bound to leave it (the player's F and Escape only + work while the player has focus, and it need not even be the visible tab). + Fullscreen is now never restored at startup, leaving it is done before the + geometry is saved, and **F11 toggles fullscreen and Escape leaves it from + anywhere in the window**. +- **"Sign in with cookies" and the account button did nothing.** Both open the + cookie dialog on its Cookies tab, which meant calling `setCurrentIndex` on + the dialog's tab widget — a `SmoothTabWidget`, which despite standing in for + a `QTabWidget` implemented only `set_current_index`. The call raised, so the + dialog never appeared. `SmoothTabWidget` now provides the Qt-compatible API + it claimed to have (`setCurrentIndex`, `count`, `widget`, `indexOf`, + `tabText`, `setTabText`). + ## 5.5.0 — 2026-08-09 ### Added diff --git a/pyproject.toml b/pyproject.toml index 9e850fd..e47a74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sagetube" -version = "5.5.0" +version = "5.5.1" description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage." authors = [ { name = "Houmeres", email = "admin@ecoposta.sk" }, diff --git a/ytsage/__init__.py b/ytsage/__init__.py index 74789d3..f050ecf 100644 --- a/ytsage/__init__.py +++ b/ytsage/__init__.py @@ -10,5 +10,5 @@ match `version` in pyproject.toml and the repository's latest signed tag -- this package's tag line continues YTSage's, which is why it starts at 5.x. """ -__version__ = "5.5.0" +__version__ = "5.5.1" __author__ = "Houmeres" diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 4f728c7..d350b39 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -31,7 +31,7 @@ from PySide6.QtWidgets import ( QGraphicsScene, QGraphicsPixmapItem, ) -from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor +from PySide6.QtGui import QIcon, QPixmap, QPainter, QBrush, QColor, QShortcut, QKeySequence from .. import __version__ as APP_VERSION from ..core import ytsage_app_update as app_update # SageTube's own release check @@ -230,6 +230,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): except Exception as e: logger.debug(f"Failed to restore state: {e}") + # Never start fullscreen. restoreGeometry() replays the window state + # it was saved with, and since fullscreen became a state of the *main* + # window rather than a separate one, quitting from fullscreen used to + # bring the app back with no title bar, no close button, and no way + # out -- fullscreen is driven from the Watch tab, which may not even be + # the visible tab at startup. Maximised is kept: it is escapable. + if self.windowState() & Qt.WindowState.WindowFullScreen: + logger.info("Ignoring saved fullscreen state; starting windowed.") + self.setWindowState(self.windowState() & ~Qt.WindowState.WindowFullScreen) + def _perform_startup_checks(self): """Perform potentially blocking startup checks after UI is shown.""" @@ -598,6 +608,7 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): ) self.watch_page.player.set_fullscreen_controller(self.fullscreen_controller) self.fullscreen_controller.changed.connect(self.watch_page.player.on_fullscreen_changed) + self._install_fullscreen_shortcuts() self.router.playVideo.connect(self._route_play_video) self.router.queueVideo.connect(self.watch_page.enqueue) @@ -621,6 +632,42 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): if isinstance(self.watch_page.player, PlayerPanel): self.router.cookiesChanged.connect(self.watch_page.player.reload_auth) + def _install_fullscreen_shortcuts(self) -> None: + """ + Window-level F11 and Escape. + + The player's own F and Escape are scoped to the player, so they only + fire while it has focus. Fullscreen now belongs to the whole window, so + there must be a way out of it from anywhere -- otherwise a window with + no title bar and no close button has no exit at all. + """ + self._fullscreen_shortcuts = [] + for sequence, handler in ( + ("F11", self._toggle_fullscreen_from_window), + ("Escape", self._escape_fullscreen), + ): + shortcut = QShortcut(QKeySequence(sequence), self) + shortcut.setContext(Qt.ShortcutContext.WindowShortcut) + shortcut.activated.connect(handler) + self._fullscreen_shortcuts.append(shortcut) + + def _toggle_fullscreen_from_window(self) -> None: + controller = getattr(self, "fullscreen_controller", None) + if controller is None: + # No player, so no controller -- still honour F11 as a plain + # window toggle rather than doing nothing. + self.showNormal() if self.isFullScreen() else self.showFullScreen() + return + controller.toggle() + + def _escape_fullscreen(self) -> None: + """Escape leaves fullscreen and is otherwise ignored.""" + controller = getattr(self, "fullscreen_controller", None) + if controller is not None and controller.is_active(): + controller.exit() + elif self.isFullScreen(): + self.showNormal() + def _on_main_tab_changed(self, index: int) -> None: """ Tell a page it has become visible. @@ -1294,7 +1341,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.current_download.terminate() self.current_download.wait(1000) # Wait for termination - # Save the window size and state + # Save the window size and state. Leave fullscreen first, so what + # gets stored is the windowed geometry -- saving while fullscreen + # records a size covering the whole screen, which is not a useful + # window to come back to. + try: + if getattr(self, "fullscreen_controller", None) is not None and self.fullscreen_controller.is_active(): + self.fullscreen_controller.exit() + except Exception as e: + logger.debug(f"Could not leave fullscreen before saving geometry: {e}") + try: ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii")) ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii")) diff --git a/ytsage/gui/ytsage_player_fullscreen.py b/ytsage/gui/ytsage_player_fullscreen.py index 11647ed..58fe7f7 100644 --- a/ytsage/gui/ytsage_player_fullscreen.py +++ b/ytsage/gui/ytsage_player_fullscreen.py @@ -58,7 +58,14 @@ class FullscreenController(QObject): return self._active def toggle(self) -> None: - self.exit() if self._active else self.enter() + # Trust the window, not just the flag. If something else put the + # window into fullscreen -- a restored geometry, the window manager, + # the user's own shortcut -- toggling must get *out* of it rather than + # trying to enter a state it is already in and appearing to do nothing. + if self._active or self._window.isFullScreen(): + self.exit() + else: + self.enter() # ------------------------------------------------------------ transitions @@ -97,7 +104,9 @@ class FullscreenController(QObject): self.exit() def exit(self) -> None: - if not self._active and self._prev_window_state is None: + # Also runs when the controller was never active but the window is + # fullscreen anyway, so restoring the chrome is unconditional. + if not self._active and self._prev_window_state is None and not self._window.isFullScreen(): return try: tab_bar = getattr(self._tabs, "tab_bar", None) @@ -116,9 +125,12 @@ class FullscreenController(QObject): layout.setContentsMargins(self._prev_margins) if self._prev_window_state is not None: - self._window.setWindowState(self._prev_window_state) + # Never restore *into* fullscreen: that is what we are leaving. + self._window.setWindowState(self._prev_window_state & ~Qt.WindowState.WindowFullScreen) else: self._window.showNormal() + if self._window.isFullScreen(): + self._window.showNormal() except Exception as e: logger.exception(f"Leaving fullscreen failed: {e}") finally: diff --git a/ytsage/gui/ytsage_smooth_tab_widget.py b/ytsage/gui/ytsage_smooth_tab_widget.py index 1c03aa2..18c00bc 100644 --- a/ytsage/gui/ytsage_smooth_tab_widget.py +++ b/ytsage/gui/ytsage_smooth_tab_widget.py @@ -183,3 +183,29 @@ class SmoothTabWidget(QWidget): def currentIndex(self): return self.stack.currentIndex() + + # --- QTabWidget-compatible API --------------------------------------- + # This class claims to "behave like a QTabWidget" and is used as a drop-in + # for one (CustomOptionsDialog builds its tabs on it), but it only ever + # offered set_current_index. Anything calling the Qt spelling raised + # AttributeError -- which is why "Sign in with cookies" appeared to do + # nothing: show_cookie_login_dialog selects the Cookies tab by index + # before showing the dialog, and died on that line. + + def setCurrentIndex(self, index): + self.set_current_index(index) + + def count(self): + return self.stack.count() + + def widget(self, index): + return self.stack.widget(index) + + def indexOf(self, widget): + return self.stack.indexOf(widget) + + def tabText(self, index): + return self.tab_bar.tabText(index) + + def setTabText(self, index, text): + self.tab_bar.setTabText(index, text)