Compare commits
2 Commits
b07a56082a
...
v5.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
996d581a07
|
|||
|
ba05a4a7ef
|
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user