2 Commits

Author SHA1 Message Date
Homer 996d581a07 Show the account feed after it loads
Refresh fetched the account feed correctly -- the status line said "60 videos
in feed" -- and the page stayed empty with "No subscriptions yet" on it.

The empty-state hint added in 5.5.0 hides the grid so it can sit centred
rather than pinned under a large blank area. Every path that changes the grid
re-runs that check afterwards and shows it again; the account path was the one
that did not, so the grid stayed hidden from startup no matter what was loaded
into it. The local feed re-runs it and was unaffected, which is why this only
showed up for someone signed in.

Two related things while here. The empty-state message was written for local
subscriptions and is wrong advice in account mode, which does not use them --
it now says to press Refresh rather than to go and subscribe to something. And
the Subscriptions panel, which lists local subscriptions, is hidden in account
mode instead of sitting empty beside a full feed and implying something failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:38:07 +02:00
Homer ba05a4a7ef Never start fullscreen, and let the cookie dialog open
Two regressions from 5.5.0, both mine.

Fullscreen became a state of the main window, which is what stopped it
destroying the player's GL context -- but the window's geometry is saved on
exit and restoreGeometry() replays the state it was saved with. Quitting while
fullscreen therefore brought the app back fullscreen: no title bar, no close
button, and nothing able to leave it, because the player's F and Escape are
scoped to the player and the Watch tab need not even be visible. Fullscreen is
no longer restored at startup, it is left before the geometry is saved, and
F11/Escape are now window-level shortcuts so there is always a way out. The
controller also trusts the window rather than its own flag, so a fullscreen it
did not set is still escapable.

"Sign in with cookies" and the account button both call
show_cookie_login_dialog, which selects the Cookies tab by index before
showing the dialog. CustomOptionsDialog builds its tabs on SmoothTabWidget,
which stands in for a QTabWidget and says so in its docstring, but implemented
only set_current_index -- so setCurrentIndex raised and the dialog never
opened. That line had never run before: the method had no callers until 5.5.0
wired it up. SmoothTabWidget now provides the Qt-compatible API it claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 01:31:47 +02:00
9 changed files with 164 additions and 15 deletions
+35
View File
@@ -8,6 +8,41 @@ 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 with its history. SageTube's own versions start below and are the ones this file
records. records.
## 5.5.2 — 2026-08-09
### Fixed
- **The account feed loaded but never appeared.** Refresh reported "60 videos in
feed" above a "No subscriptions yet" message and an empty page: the empty-state
hint hides the grid so it can sit centred, and the account path set its entries
without re-running that check — so the grid stayed hidden from startup. The
local feed was unaffected, which is why it went unnoticed.
- The empty-state message was written for local subscriptions and was wrong
advice in account mode, where local subscriptions are not used at all. It now
says to press Refresh instead of telling you to go and subscribe to something.
- The Subscriptions panel is hidden in account mode rather than sitting empty
beside a full feed, implying something failed.
## 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 ## 5.5.0 — 2026-08-09
### Added ### Added
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "sagetube" name = "sagetube"
version = "5.5.0" version = "5.5.2"
description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage." description = "Watch-first YouTube client with embedded mpv playback and yt-dlp downloading. Fork of YTSage."
authors = [ authors = [
{ name = "Houmeres", email = "admin@ecoposta.sk" }, { name = "Houmeres", email = "admin@ecoposta.sk" },
+1 -1
View File
@@ -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. this package's tag line continues YTSage's, which is why it starts at 5.x.
""" """
__version__ = "5.5.0" __version__ = "5.5.2"
__author__ = "Houmeres" __author__ = "Houmeres"
+24 -6
View File
@@ -148,7 +148,10 @@ class FeedPage(QWidget):
splitter = QSplitter(Qt.Orientation.Horizontal, self) splitter = QSplitter(Qt.Orientation.Horizontal, self)
layout.addWidget(splitter, stretch=1) layout.addWidget(splitter, stretch=1)
side = QWidget() # Kept as an attribute: account mode does not use local subscriptions,
# so the panel is hidden there rather than sitting empty beside a full
# feed and implying something is missing.
self.subs_panel = side = QWidget()
side_layout = QVBoxLayout(side) side_layout = QVBoxLayout(side)
side_layout.setContentsMargins(0, 0, 4, 0) side_layout.setContentsMargins(0, 0, 4, 0)
subs_label = QLabel(_("feed.subscriptions")) subs_label = QLabel(_("feed.subscriptions"))
@@ -352,16 +355,26 @@ class FeedPage(QWidget):
Feed is the first tab now, so an empty one is the first thing a new Feed is the first tab now, so an empty one is the first thing a new
user sees. Say what to do about it rather than showing a blank grid. user sees. Say what to do about it rather than showing a blank grid.
""" """
account_mode = (self.mode_combo.currentData() or "local") == "account"
self.subs_panel.setVisible(not account_mode)
self.grid.empty_label.hide() self.grid.empty_label.hide()
if self.grid.card_count() > 0: if self.grid.card_count() > 0:
self.empty_hint.hide() self.empty_hint.hide()
self.grid.show() self.grid.show()
return return
self.empty_hint.setText(
_("feed.empty_no_subscriptions") # The message depends on the mode: account mode does not use local
if not LibraryManager.subscriptions() # subscriptions at all, so telling someone to go and subscribe to
else _("feed.empty_not_refreshed") # something would be wrong advice.
) if account_mode:
message = _("feed.empty_account")
elif not LibraryManager.subscriptions():
message = _("feed.empty_no_subscriptions")
else:
message = _("feed.empty_not_refreshed")
self.empty_hint.setText(message)
# Hide the empty grid so the hint sits in the middle of the page # Hide the empty grid so the hint sits in the middle of the page
# rather than pinned under a large blank area. # rather than pinned under a large blank area.
self.grid.hide() self.grid.hide()
@@ -383,10 +396,15 @@ class FeedPage(QWidget):
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None: def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
self.grid.set_entries(entries) self.grid.set_entries(entries)
self.status_label.setText(_("feed.refreshed", count=len(entries))) self.status_label.setText(_("feed.refreshed", count=len(entries)))
# Without this the grid stays hidden behind the empty-state hint that
# was shown at startup, so a successful refresh reported "60 videos in
# feed" above a "No subscriptions yet" message and no videos.
self._update_empty_state()
def _on_account_error(self, message: str) -> None: def _on_account_error(self, message: str) -> None:
logger.error(f"Account feed failed: {message}") logger.error(f"Account feed failed: {message}")
self.status_label.setText(_("feed.account_failed", error=message[:200])) self.status_label.setText(_("feed.account_failed", error=message[:200]))
self._update_empty_state()
def _on_account_finished(self) -> None: def _on_account_finished(self) -> None:
self.refresh_btn.setEnabled(True) self.refresh_btn.setEnabled(True)
+58 -2
View File
@@ -31,7 +31,7 @@ from PySide6.QtWidgets import (
QGraphicsScene, QGraphicsScene,
QGraphicsPixmapItem, 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 .. import __version__ as APP_VERSION
from ..core import ytsage_app_update as app_update # SageTube's own release check 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: except Exception as e:
logger.debug(f"Failed to restore state: {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): def _perform_startup_checks(self):
"""Perform potentially blocking startup checks after UI is shown.""" """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.watch_page.player.set_fullscreen_controller(self.fullscreen_controller)
self.fullscreen_controller.changed.connect(self.watch_page.player.on_fullscreen_changed) 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.playVideo.connect(self._route_play_video)
self.router.queueVideo.connect(self.watch_page.enqueue) 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): if isinstance(self.watch_page.player, PlayerPanel):
self.router.cookiesChanged.connect(self.watch_page.player.reload_auth) 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: def _on_main_tab_changed(self, index: int) -> None:
""" """
Tell a page it has become visible. Tell a page it has become visible.
@@ -1294,7 +1341,16 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin):
self.current_download.terminate() self.current_download.terminate()
self.current_download.wait(1000) # Wait for termination 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: try:
ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii")) ConfigManager.set("window_geometry", self.saveGeometry().toBase64().data().decode("ascii"))
ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii")) ConfigManager.set("window_state", self.saveState().toBase64().data().decode("ascii"))
+15 -3
View File
@@ -58,7 +58,14 @@ class FullscreenController(QObject):
return self._active return self._active
def toggle(self) -> None: 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 # ------------------------------------------------------------ transitions
@@ -97,7 +104,9 @@ class FullscreenController(QObject):
self.exit() self.exit()
def exit(self) -> None: 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 return
try: try:
tab_bar = getattr(self._tabs, "tab_bar", None) tab_bar = getattr(self._tabs, "tab_bar", None)
@@ -116,9 +125,12 @@ class FullscreenController(QObject):
layout.setContentsMargins(self._prev_margins) layout.setContentsMargins(self._prev_margins)
if self._prev_window_state is not None: 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: else:
self._window.showNormal() self._window.showNormal()
if self._window.isFullScreen():
self._window.showNormal()
except Exception as e: except Exception as e:
logger.exception(f"Leaving fullscreen failed: {e}") logger.exception(f"Leaving fullscreen failed: {e}")
finally: finally:
+26
View File
@@ -183,3 +183,29 @@ class SmoothTabWidget(QWidget):
def currentIndex(self): def currentIndex(self):
return self.stack.currentIndex() 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)
+2 -1
View File
@@ -760,7 +760,8 @@
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.", "empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
"sign_in": "Sign in with cookies…", "sign_in": "Sign in with cookies…",
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed", "sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
"account_requires_cookies": "Requires signing in with cookies" "account_requires_cookies": "Requires signing in with cookies",
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
}, },
"account": { "account": {
"signed_out": "Not signed in", "signed_out": "Not signed in",
+2 -1
View File
@@ -143,7 +143,8 @@ class LocalizationManager:
"empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.", "empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions.",
"sign_in": "Sign in with cookies…", "sign_in": "Sign in with cookies…",
"sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed", "sign_in_tooltip": "Use your YouTube account via browser cookies, which enables the account feed",
"account_requires_cookies": "Requires signing in with cookies" "account_requires_cookies": "Requires signing in with cookies",
"empty_account": "Nothing loaded yet.\n\nPress Refresh to fetch your YouTube subscription feed."
}, },
"cards": { "cards": {
"play": "▶ Play", "play": "▶ Play",