diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bebfa..71c07ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,40 @@ records. application had a tooltip. An application-level polish filter now fills them in as each button is first shown, and swaps placeholder emoji for icons — which covers dialogs owned by upstream without editing them. +- **Skip this version** in the update dialog. The skipped version is never + offered again; later ones still are. + +### Changed + +- **The tab order is Feed → Search → Browse → Downloads → Watch**, each with an + icon, and the app opens on the Feed. Watch was first, which is where the + other tabs send you rather than somewhere you start. +- **Opening the Feed refreshes it**, but only channels not seen for 30 minutes + (`feed.auto_refresh_on_open_minutes`, 0 to switch off), at most eight per + visit, once per interval per session, and after a short delay. A refresh is + one yt-dlp subprocess per subscribed channel, so an unthrottled one would be + a lot of them. The cached feed still appears instantly; manual Refresh always + does everything. +- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default + `auto`), each ending in software decoding, so a machine with broken GPU + interop plays rather than showing a black frame. This does **not** silence + `Cannot load libcuda.so.1` — that comes from the driver stack below mpv and + appears with `hwdec=no` too. +- **The update check now looks at SageTube's own releases.** It queried PyPI's + `ytsage` package for the version and `oop7/YTSage` for the changelog, then + linked to upstream's downloads — a different program's release stream. It now + reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the + "Download update" button opens this repository's release page. +- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags + (`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be + offered one. +- The check now runs at most once a day rather than on every start. +- The About dialog and the update settings say SageTube rather than YTSage. The + "Based on YTSage by oop7" attribution link stays — it is the MIT credit. +- Configs now carry `config_version`. A file written before 5.4.0 — including one + inherited from an upstream YTSage install — has its stored `check_app_updates` + cleared once, so SageTube's own default applies rather than a setting that + pointed at another project's releases. ### Fixed @@ -57,6 +91,28 @@ records. - The window icon shipped only at 48px and was upscaled everywhere; the 256px master now ships beside it. Its fallback was the platform's download arrow standing in for the application's identity. +- **The feed grid no longer flickers or loses your scroll position.** It was + cleared and rebuilt from scratch after every channel finished, re-reading + every thumbnail from disk each time; finished channels are now merged in and + existing cards left alone. The docstring's claim that it "fills + incrementally" is finally true. +- Card layout items accumulated: each `Load more` re-added cards the layout + already held, and the resize check compared against `columnCount()`, which + never shrinks — so it relaid out on every single resize event. +- Feed failures were invisible. The error was written into the same label the + next successful channel immediately overwrote; the count is now reported once + when the refresh ends. +- Switching the feed to account mode left the local videos on screen, so one + mode's contents appeared to be the other's. +- Leaving the Feed tab or closing the window cancels a running refresh. + `cancel()` existed and had no callers, so a sweep kept running and the window + could be closed while its thread was live. +- Browse's Subscribe button never said "Unsubscribe", although pressing it + toggles; its only feedback was a status line on a different tab. +- "Play all" quietly queued just the loaded page and claimed to be everything. +- The feed sorted by publish time and fetch time in the same expression, so the + most recently refreshed channel floated to the top regardless of how old its + videos were. - **The player no longer crashes the app.** Qt destroys and recreates a widget's OpenGL context whenever it moves to another top-level window, and calls `initializeGL()` again. libmpv allows one render context per handle, so @@ -83,33 +139,6 @@ records. reason — every dialog in the app screenshotted the whole window. - The saved volume never reached mpv: the slider set its restored value before its change signal was connected, so playback always started at 100. - -### Changed - -- mpv is given an explicit `hwdec` list per platform (`player.hwdec`, default - `auto`), each ending in software decoding, so a machine with broken GPU - interop plays rather than showing a black frame. This does **not** silence - `Cannot load libcuda.so.1` — that comes from the driver stack below mpv and - appears with `hwdec=no` too. -- **The update check now looks at SageTube's own releases.** It queried PyPI's - `ytsage` package for the version and `oop7/YTSage` for the changelog, then - linked to upstream's downloads — a different program's release stream. It now - reads `git.houmeres.sk/Houmeres/SageTube` releases, anonymously, and the - "Download update" button opens this repository's release page. -- The beta channel follows SageTube pre-releases. Upstream's inherited `b` tags - (`v5.3.0b` and earlier) read as pre-releases, so a stable instance cannot be - offered one. -- The check now runs at most once a day rather than on every start. -- The About dialog and the update settings say SageTube rather than YTSage. The - "Based on YTSage by oop7" attribution link stays — it is the MIT credit. - -### Added - -- **Skip this version** in the update dialog. The skipped version is never - offered again; later ones still are. - -### Fixed - - **The app no longer offers upstream YTSage's releases as its own updates.** `__version__` and `pyproject.toml` had been left at the scaffolded `0.1.0` while the released tag was `v5.4.0`, so every comparison against upstream's @@ -124,13 +153,6 @@ records. the settings tab read a missing `check_app_updates` as enabled and then persisted that reading unconditionally. -### Changed - -- Configs now carry `config_version`. A file written before 5.4.0 — including one - inherited from an upstream YTSage install — has its stored `check_app_updates` - cleared once, so SageTube's own default applies rather than a setting that - pointed at another project's releases. - ## 5.4.0 — 2026-08-08 ### Changed diff --git a/ytsage/gui/ytsage_gui_browse.py b/ytsage/gui/ytsage_gui_browse.py index 7934846..6129f6d 100644 --- a/ytsage/gui/ytsage_gui_browse.py +++ b/ytsage/gui/ytsage_gui_browse.py @@ -27,6 +27,7 @@ from PySide6.QtWidgets import ( from .ytsage_gui_cards import VideoCardGrid from ..core.ytsage_client import YtdlpWorker +from ..utils.ytsage_library_manager import LibraryManager from ..utils.ytsage_localization import _ from ..utils.ytsage_logger import logger @@ -239,6 +240,9 @@ class BrowsePage(QWidget): } if self._channel_meta.get("title"): self.title_label.setText(self._channel_meta["title"]) + # Now that the real channel id is known, the button can say whether + # this channel is already followed. + self.refresh_subscribe_state() def _on_meta_finished(self) -> None: if self._meta_worker is not None: @@ -250,13 +254,30 @@ class BrowsePage(QWidget): if not meta.get("title"): meta["title"] = self.title_label.text() self.subscribeRequested.emit(meta) + # The handler toggles, so reflect the new state here. Previously the + # button always read "Subscribe" whichever way it had just gone, and + # the only feedback was a status line on a different tab. + self.refresh_subscribe_state() + + def refresh_subscribe_state(self) -> None: + """Make the button say what pressing it will now do.""" + channel_id = self._channel_meta.get("channel_id") or self._channel_meta.get("url") or self._current_url + subscribed = bool(channel_id) and LibraryManager.is_subscribed(str(channel_id)) + self.subscribe_btn.setText(_("browse.unsubscribe") if subscribed else _("browse.subscribe")) + self.subscribe_btn.setToolTip( + _("browse.unsubscribe_tooltip") if subscribed else _("browse.subscribe_tooltip") + ) def _on_play_all(self) -> None: - for entry in [c.entry for c in self.playlist_section.grid._cards]: + cards = list(self.playlist_section.grid._cards) + for entry in [c.entry for c in cards]: e = dict(entry) if not e.get("url") and e.get("id"): e["url"] = f"https://www.youtube.com/watch?v={e['id']}" self._router.queueVideo.emit(e) + # "Play all" queues what has been loaded, which is one page unless the + # user pressed Load more. Say so rather than implying the whole list. + self.playlist_section.status_label.setText(_("browse.queued_loaded", count=len(cards))) def _on_download_playlist(self) -> None: if self._current_url: diff --git a/ytsage/gui/ytsage_gui_cards.py b/ytsage/gui/ytsage_gui_cards.py index d1dd37e..b48c549 100644 --- a/ytsage/gui/ytsage_gui_cards.py +++ b/ytsage/gui/ytsage_gui_cards.py @@ -207,6 +207,9 @@ class VideoCardGrid(QScrollArea): super().__init__(parent) self._router = router self._cards: List[VideoCard] = [] + #: video id -> card, so merge_entries can tell new from existing + self._by_key: Dict[str, "VideoCard"] = {} + self._columns_in_use = 0 self.setWidgetResizable(True) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -246,15 +249,56 @@ class VideoCardGrid(QScrollArea): for entry in entries: card = VideoCard(entry, self._router, self._grid_widget) self._cards.append(card) + self._by_key[self._key(entry)] = card self._relayout() self.load_more_btn.setVisible(show_load_more) self.empty_label.setVisible(not self._cards) + @staticmethod + def _key(entry: Dict[str, Any]) -> str: + return str(entry.get("id") or entry.get("url") or id(entry)) + + def merge_entries(self, entries: List[Dict[str, Any]], prune: bool = False) -> None: + """ + Fold entries in, keeping cards that are already here. + + set_entries() destroys and rebuilds every card, which during a feed + refresh happened once per channel: the grid flickered, the scroll + position was lost each time, and every thumbnail was re-read from + disk. Existing cards are left alone here, so only genuinely new + videos cost anything. + """ + incoming = {self._key(e): e for e in entries} + scroll = self.verticalScrollBar().value() + self.setUpdatesEnabled(False) + try: + for key, entry in incoming.items(): + if key in self._by_key: + continue + card = VideoCard(entry, self._router, self._grid_widget) + self._cards.append(card) + self._by_key[key] = card + + if prune: + for key in [k for k in self._by_key if k not in incoming]: + card = self._by_key.pop(key) + if card in self._cards: + self._cards.remove(card) + self._grid.removeWidget(card) + card.deleteLater() + + self._relayout() + self.empty_label.setVisible(not self._cards) + finally: + self.setUpdatesEnabled(True) + self.verticalScrollBar().setValue(scroll) + def clear(self) -> None: for card in self._cards: self._grid.removeWidget(card) card.deleteLater() self._cards = [] + self._by_key.clear() self.empty_label.setVisible(True) self.load_more_btn.setVisible(False) @@ -269,10 +313,19 @@ class VideoCardGrid(QScrollArea): def _relayout(self) -> None: cols = self._columns() + # Drain first. addWidget on a card the layout already owns adds a + # second item for it, so the layout grew an extra entry per card on + # every append. takeAt detaches without deleting the widget. + while self._grid.count(): + self._grid.takeAt(0) for i, card in enumerate(self._cards): self._grid.addWidget(card, i // cols, i % cols) + self._columns_in_use = cols def resizeEvent(self, event) -> None: super().resizeEvent(event) - if self._cards and self._columns() != self._grid.columnCount(): + # Compared against what was actually laid out, not columnCount(): + # QGridLayout never shrinks its column count, so that comparison + # stayed true forever and relaid out on every resize event. + if self._cards and self._columns() != self._columns_in_use: self._relayout() diff --git a/ytsage/gui/ytsage_gui_feed.py b/ytsage/gui/ytsage_gui_feed.py index 0dc9e18..3acd242 100644 --- a/ytsage/gui/ytsage_gui_feed.py +++ b/ytsage/gui/ytsage_gui_feed.py @@ -16,7 +16,7 @@ Browse page's Subscribe button routes here via the main window. import time from typing import Any, Dict, List, Optional -from PySide6.QtCore import QThread, Qt, Signal +from PySide6.QtCore import QThread, QTimer, Qt, Signal from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, @@ -38,6 +38,18 @@ from ..utils.ytsage_localization import _ from ..utils.ytsage_logger import logger +#: Only refresh a channel automatically if it is at least this stale. +AUTO_REFRESH_DEFAULT_MINUTES = 30 +#: Channels touched per automatic refresh. A manual Refresh still does all of +#: them; this only bounds what opening the tab can set off. +AUTO_REFRESH_BATCH = 8 +#: Let the tab transition finish before spawning subprocesses. +AUTO_REFRESH_DELAY_MS = 1500 +#: The automatic path gives up sooner than a manual refresh: nobody is +#: watching it, and a stuck channel must not block the rest. +AUTO_REFRESH_TIMEOUT = 45 + + class FeedRefreshWorker(QThread): """Sequentially refresh each subscription's recent uploads.""" @@ -45,10 +57,17 @@ class FeedRefreshWorker(QThread): channelFailed = Signal(str, str) # channel_id, error allDone = Signal() - def __init__(self, subscriptions: List[Dict[str, Any]], per_channel: int, parent=None) -> None: + def __init__( + self, + subscriptions: List[Dict[str, Any]], + per_channel: int, + parent=None, + timeout: Optional[int] = None, + ) -> None: super().__init__(parent) self._subs = subscriptions self._per_channel = per_channel + self._timeout = timeout self._cancelled = False def cancel(self) -> None: @@ -60,8 +79,15 @@ class FeedRefreshWorker(QThread): if self._cancelled: break try: + kwargs = {} + if self._timeout is not None: + kwargs["timeout"] = self._timeout entries = client.fetch_flat_entries( - f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False + f"{sub['url'].rstrip('/')}/videos", + start=1, + end=self._per_channel, + use_cache=False, + **kwargs, ) LibraryManager.upsert_feed_items(sub["channel_id"], entries) LibraryManager.mark_refreshed(sub["channel_id"]) @@ -73,11 +99,20 @@ class FeedRefreshWorker(QThread): class FeedPage(QWidget): + #: Emitted after subscribe/unsubscribe so other pages can re-read state. + subscriptionsChanged = Signal() + def __init__(self, router, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._router = router self._refresh_worker: Optional[FeedRefreshWorker] = None self._account_worker: Optional[YtdlpWorker] = None + #: monotonic timestamp of the last automatic refresh this session + self._last_auto_refresh: float = 0.0 + self._done_count = 0 + self._failed_count = 0 + self._total_count = 0 + self._last_error = "" layout = QVBoxLayout(self) layout.setContentsMargins(8, 8, 8, 8) @@ -117,8 +152,25 @@ class FeedPage(QWidget): side_layout.addWidget(self.subs_list) splitter.addWidget(side) - self.grid = VideoCardGrid(router, self) - splitter.addWidget(self.grid) + grid_host = QWidget() + grid_layout = QVBoxLayout(grid_host) + grid_layout.setContentsMargins(0, 0, 0, 0) + self.grid = VideoCardGrid(router, grid_host) + grid_layout.addWidget(self.grid, stretch=1) + + # Feed is the first tab, so an empty feed is the first thing a new + # install shows. Say what to do about it. + self.empty_hint = QLabel("") + self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_hint.setWordWrap(True) + self.empty_hint.setStyleSheet("color: #9aa0a6; padding: 24px; font-size: 13px;") + self.empty_hint.hide() + grid_layout.addWidget(self.empty_hint, stretch=1) + # The grid carries its own bare "Nothing here yet"; two empty states + # stacked on one page is one too many. + self.grid.empty_label.hide() + + splitter.addWidget(grid_host) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 4) splitter.setSizes([220, 900]) @@ -142,6 +194,8 @@ class FeedPage(QWidget): LibraryManager.subscribe(str(channel_id), meta.get("title") or str(channel_id), meta["url"], meta.get("avatar_url")) self.status_label.setText(_("feed.subscribed", title=meta.get("title") or channel_id)) self.reload_subscriptions() + self._update_empty_state() + self.subscriptionsChanged.emit() def reload_subscriptions(self) -> None: self.subs_list.clear() @@ -158,21 +212,68 @@ class FeedPage(QWidget): else: self._refresh_local() + # --------------------------------------------------- refresh on opening + + def on_tab_activated(self) -> None: + """ + Called by the main window when the Feed tab becomes visible. + + The cached grid is already on screen -- it is loaded in the + constructor and kept up to date -- so this only decides whether to go + and fetch. A refresh is one yt-dlp subprocess per subscribed channel, + so it is braked four ways: only channels that are actually stale, at + most one automatic run per interval per session, a batch cap, and a + short delay so it does not race the tab transition or first-run setup. + """ + if self._refresh_worker is not None or self._account_worker is not None: + return + if (self.mode_combo.currentData() or "local") != "local": + return + + minutes = ConfigManager.get("feed.auto_refresh_on_open_minutes") + minutes = AUTO_REFRESH_DEFAULT_MINUTES if minutes is None else int(minutes) + if minutes <= 0: + return # opted out + + now = time.monotonic() + if self._last_auto_refresh and (now - self._last_auto_refresh) < minutes * 60: + return + + cutoff = time.time() - minutes * 60 + stale = [s for s in LibraryManager.subscriptions() if not s.get("last_refreshed") or s["last_refreshed"] < cutoff] + if not stale: + return + + self._last_auto_refresh = now + batch = stale[:AUTO_REFRESH_BATCH] + if len(stale) > len(batch): + logger.info(f"Feed auto-refresh: {len(batch)} of {len(stale)} stale channels this time.") + QTimer.singleShot(AUTO_REFRESH_DELAY_MS, lambda: self._refresh_local(batch, auto=True)) + # --------------------------------------------------------------- local - def _refresh_local(self) -> None: + def _refresh_local(self, subs: Optional[List[Dict[str, Any]]] = None, auto: bool = False) -> None: if self._refresh_worker is not None: return - subs = LibraryManager.subscriptions() + if subs is None: + subs = LibraryManager.subscriptions() if not subs: - self.status_label.setText(_("feed.no_subscriptions")) + if not auto: + self.status_label.setText(_("feed.no_subscriptions")) return per_channel = int(ConfigManager.get("feed.per_channel_items") or 15) self.refresh_btn.setEnabled(False) self.status_label.setText(_("feed.refreshing", done=0, total=len(subs))) self._done_count = 0 + self._failed_count = 0 + self._last_error = "" self._total_count = len(subs) - self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self) + self._refresh_worker = FeedRefreshWorker( + subs, + per_channel, + parent=self, + timeout=AUTO_REFRESH_TIMEOUT if auto else None, + ) self._refresh_worker.channelDone.connect(self._on_channel_done) self._refresh_worker.channelFailed.connect(self._on_channel_failed) self._refresh_worker.allDone.connect(self._on_refresh_done) @@ -181,22 +282,36 @@ class FeedPage(QWidget): def _on_channel_done(self, channel_id: str) -> None: self._done_count += 1 self.status_label.setText(_("feed.refreshing", done=self._done_count, total=self._total_count)) - self._load_cached_feed() + # Merge only this channel's rows. Re-reading the whole feed here + # rebuilt every card after every channel: visible flicker, the scroll + # position lost each time, and thumbnails re-read from disk. + self._merge_channel(channel_id) def _on_channel_failed(self, channel_id: str, error: str) -> None: self._done_count += 1 - self.status_label.setText(_("feed.channel_failed", error=error[:120])) + self._failed_count += 1 + # Do not write the error into the status line here: the next + # channelDone overwrites it immediately, so failures were invisible. + # It is reported once at the end instead. + self._last_error = error def _on_refresh_done(self) -> None: self.refresh_btn.setEnabled(True) - self.status_label.setText(_("feed.refreshed", count=self.grid.card_count())) + if self._failed_count: + self.status_label.setText( + _("feed.refreshed_with_failures", count=self.grid.card_count(), failed=self._failed_count) + ) + logger.warning(f"Feed refresh: {self._failed_count} channel(s) failed; last error: {self._last_error[:200]}") + else: + self.status_label.setText(_("feed.refreshed", count=self.grid.card_count())) + self._update_empty_state() if self._refresh_worker is not None: self._refresh_worker.deleteLater() self._refresh_worker = None - def _load_cached_feed(self) -> None: - items = LibraryManager.feed_items() - entries = [ + @staticmethod + def _to_entries(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [ { "id": it["video_id"], "url": it["url"], @@ -207,7 +322,41 @@ class FeedPage(QWidget): } for it in items ] - self.grid.set_entries(entries) + + def _load_cached_feed(self) -> None: + self.grid.set_entries(self._to_entries(LibraryManager.feed_items())) + self._update_empty_state() + + def _merge_channel(self, channel_id: str) -> None: + """Fold one channel's fresh rows into the grid without rebuilding it.""" + try: + items = LibraryManager.feed_items(channel_id=channel_id) + except TypeError: + # Older signature without the filter: fall back to a full reload. + self._load_cached_feed() + return + self.grid.merge_entries(self._to_entries(items)) + self._update_empty_state() + + def _update_empty_state(self) -> None: + """ + 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. + """ + self.grid.empty_label.hide() + if self.grid.card_count() > 0: + self.empty_hint.hide() + self.grid.show() + return + self.empty_hint.setText( + _("feed.empty_no_subscriptions") + if not LibraryManager.subscriptions() + else _("feed.empty_not_refreshed") + ) + # Hide the empty grid so the hint sits in the middle of the page + # rather than pinned under a large blank area. + self.grid.hide() + self.empty_hint.show() # -------------------------------------------------------------- account @@ -251,8 +400,37 @@ class FeedPage(QWidget): self._update_mode_availability() super().showEvent(event) + def hideEvent(self, event) -> None: + # Leaving the tab cancels an automatic refresh. cancel() existed and + # was never called, so a background sweep kept running -- and closing + # the window destroyed a live QThread. + self._cancel_refresh() + super().hideEvent(event) + + def _cancel_refresh(self) -> None: + worker = self._refresh_worker + if worker is None: + return + worker.cancel() + if not worker.wait(3000): + logger.warning("Feed refresh did not stop in time.") + + def shutdown(self) -> None: + """Called on application close.""" + self._cancel_refresh() + def _on_mode_changed(self) -> None: - ConfigManager.set("feed.mode", self.mode_combo.currentData()) + mode = self.mode_combo.currentData() + ConfigManager.set("feed.mode", mode) + # Switching modes left the previous mode's items on screen, so the + # local feed appeared to be the account feed. Show what the new mode + # actually has. + if mode == "local": + self._load_cached_feed() + else: + self.grid.set_entries([]) + self.status_label.setText(_("feed.account_needs_refresh")) + self._update_empty_state() def _on_sub_activated(self, item: QListWidgetItem) -> None: sub = item.data(Qt.ItemDataRole.UserRole) diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 4c9daff..4f49ab5 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -576,12 +576,17 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.feed_page = FeedPage(self.router, self) self.browse_page = BrowsePage(self.router, self) + # Feed first, Watch last. The feed is what you open the app to see; + # the player is where the other tabs send you, not somewhere you + # start. _tab_index_of resolves by widget identity, so the routing + # methods below are unaffected by this order. self.main_tabs = SmoothTabWidget(self) - self.main_tabs.addTab(self.watch_page, _("main_tabs.watch")) - self.main_tabs.addTab(self.search_page, _("main_tabs.search")) - self.main_tabs.addTab(self.feed_page, _("main_tabs.feed")) - self.main_tabs.addTab(self.browse_page, _("main_tabs.browse")) - self.main_tabs.addTab(self.download_page, _("main_tabs.downloads")) + self.main_tabs.addTab(self.feed_page, _("main_tabs.feed"), icons.icon("rss", theme.ICON)) + self.main_tabs.addTab(self.search_page, _("main_tabs.search"), icons.icon("search", theme.ICON)) + self.main_tabs.addTab(self.browse_page, _("main_tabs.browse"), icons.icon("library", theme.ICON)) + self.main_tabs.addTab(self.download_page, _("main_tabs.downloads"), icons.icon("download", theme.ICON)) + self.main_tabs.addTab(self.watch_page, _("main_tabs.watch"), icons.icon("play", theme.ICON)) + self.main_tabs.currentChanged.connect(self._on_main_tab_changed) self.setCentralWidget(self.main_tabs) # Fullscreen is driven from here because the chrome it hides -- the tab @@ -599,6 +604,25 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): self.router.openChannel.connect(self._route_open_channel) self.router.openPlaylist.connect(self._route_open_playlist) self.browse_page.subscribeRequested.connect(self.feed_page.subscribe_channel) + # A new subscription changes what the Feed's empty state should say. + self.feed_page.subscriptionsChanged.connect(self.browse_page.refresh_subscribe_state) + + def _on_main_tab_changed(self, index: int) -> None: + """ + Tell a page it has become visible. + + Duck-typed rather than isinstance-checked so a page opts in by simply + defining the method. Preferred over showEvent, which also fires on + window restore and on first show. + """ + page = self.main_tabs.stack.widget(index) + hook = getattr(page, "on_tab_activated", None) + if hook is None: + return + try: + hook() + except Exception as e: + logger.debug(f"Tab activation hook failed: {e}") def _tab_index_of(self, page) -> int: for i in range(self.main_tabs.stack.count()): @@ -1211,6 +1235,13 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): except Exception as e: logger.debug(f"Watch page shutdown error: {e}") + # Stop a feed refresh before its QThread is destroyed with it. + if hasattr(self, "feed_page"): + try: + self.feed_page.shutdown() + except Exception as e: + logger.debug(f"Feed page shutdown error: {e}") + # Stop the analysis thread if it's running if hasattr(self, "_analysis_thread") and self._analysis_thread is not None and self._analysis_thread.isRunning(): logger.info("Stopping analysis thread...") diff --git a/ytsage/gui/ytsage_smooth_tab_widget.py b/ytsage/gui/ytsage_smooth_tab_widget.py index 237c9bd..1c03aa2 100644 --- a/ytsage/gui/ytsage_smooth_tab_widget.py +++ b/ytsage/gui/ytsage_smooth_tab_widget.py @@ -1,9 +1,10 @@ -from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt +from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, Signal from PySide6.QtGui import QPixmap from PySide6.QtOpenGLWidgets import QOpenGLWidget from PySide6.QtWidgets import ( QFrame, QGraphicsOpacityEffect, + QHBoxLayout, QLabel, QStackedWidget, QTabBar, @@ -94,20 +95,38 @@ class SmoothTabWidget(QWidget): A unified Widget that behaves like a QTabWidget but uses smooth fading transitions. Includes a QTabBar and a FadingStackedWidget. """ + #: Emitted after the visible page has actually changed. The QTabBar's own + #: currentChanged is not usable for this: it fires before the stack has + #: switched, and it re-enters through set_current_index. + currentChanged = Signal(int) + def __init__(self, parent=None): super().__init__(parent) - + # Main Layout self.layout = QVBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) - - # Tab Bar - self.tab_bar = QTabBar(self) - self.tab_bar.setDrawBase(False) # We draw border on content instead + + # Tab bar, plus a right-aligned slot for a corner widget. Wrapped in a + # row so something (the account button) can sit opposite the tabs the + # way QTabWidget::setCornerWidget would allow. + self.tab_row = QWidget(self) + tab_row_layout = QHBoxLayout(self.tab_row) + tab_row_layout.setContentsMargins(0, 0, 0, 0) + tab_row_layout.setSpacing(0) + + self.tab_bar = QTabBar(self.tab_row) + self.tab_bar.setDrawBase(False) # We draw border on content instead self.tab_bar.currentChanged.connect(self.set_current_index) - self.layout.addWidget(self.tab_bar) - + tab_row_layout.addWidget(self.tab_bar) + tab_row_layout.addStretch(1) + + self.corner_widget = None + self._switching = False + self._tab_row_layout = tab_row_layout + self.layout.addWidget(self.tab_row) + # Content Area (Frame) - Mimics QTabWidget::pane self.content_frame = QFrame(self) self.content_frame.setObjectName("tabContent") @@ -123,15 +142,41 @@ class SmoothTabWidget(QWidget): self.layout.addWidget(self.content_frame) - def addTab(self, widget, label): - """Add a tab with the given widget and label.""" + def addTab(self, widget, label, icon=None): + """Add a tab with the given widget, label and optional icon.""" self.stack.addWidget(widget) - self.tab_bar.addTab(label) + if icon is not None: + self.tab_bar.addTab(icon, label) + else: + self.tab_bar.addTab(label) + + def setCornerWidget(self, widget): + """Place a widget at the right-hand end of the tab row.""" + if self.corner_widget is not None: + self.corner_widget.setParent(None) + self.corner_widget = widget + if widget is not None: + widget.setParent(self.tab_row) + self._tab_row_layout.addWidget(widget) def set_current_index(self, index): """Slot to handle tab bar clicks.""" - self.tab_bar.setCurrentIndex(index) - self.stack.setCurrentIndex(index) + if index == self.stack.currentIndex() and self.tab_bar.currentIndex() == index: + return + # setCurrentIndex on the bar emits its own currentChanged, which is + # connected straight back here -- and at that moment the stack has not + # moved yet, so a plain index comparison does not catch the re-entry. + # Without this flag every switch emitted currentChanged twice and each + # page's activation hook ran twice. + if self._switching: + return + self._switching = True + try: + self.tab_bar.setCurrentIndex(index) + self.stack.setCurrentIndex(index) + finally: + self._switching = False + self.currentChanged.emit(index) def currentWidget(self): return self.stack.currentWidget() diff --git a/ytsage/languages/en.json b/ytsage/languages/en.json index 2ee3149..2af55ac 100644 --- a/ytsage/languages/en.json +++ b/ytsage/languages/en.json @@ -735,7 +735,8 @@ "subscribe_tooltip": "Follow this channel so its uploads appear in your Feed", "unsubscribe_tooltip": "Stop following this channel", "play_all_tooltip": "Queue everything loaded here", - "download_playlist_tooltip": "Open this playlist in Downloads" + "download_playlist_tooltip": "Open this playlist in Downloads", + "queued_loaded": "Queued {count} loaded videos — press Load more first to queue the rest" }, "feed": { "mode_local": "Local subscriptions", @@ -752,6 +753,10 @@ "unsubscribed": "Unsubscribed from {title}", "open_channel": "Open channel", "refresh_tooltip": "Fetch the latest uploads from every subscribed channel", - "mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account" + "mode_tooltip": "Where the feed comes from: your local subscriptions, or your signed-in YouTube account", + "refreshed_with_failures": "{count} videos — {failed} channel(s) could not be reached", + "account_needs_refresh": "Press Refresh to load your account feed", + "empty_no_subscriptions": "No subscriptions yet.\n\nFind channels in Search or Browse, then use Subscribe to follow them — their new uploads will appear here.", + "empty_not_refreshed": "Nothing here yet.\n\nPress Refresh to fetch the latest uploads from your subscriptions." } } diff --git a/ytsage/utils/ytsage_config_manager.py b/ytsage/utils/ytsage_config_manager.py index 9797ae7..fff66cb 100644 --- a/ytsage/utils/ytsage_config_manager.py +++ b/ytsage/utils/ytsage_config_manager.py @@ -128,6 +128,11 @@ class ConfigManager: "mode": "local", # local (per-channel aggregation) | account (cookies) "per_channel_items": 15, "auto_refresh_minutes": 0, # 0 = manual refresh only + # Opening the Feed tab refreshes channels not seen for this long. + # 0 disables it. A separate key from auto_refresh_minutes above so + # existing configs, which store that as 0, are not read as opting + # out of a feature that did not exist when they were written. + "auto_refresh_on_open_minutes": 30, }, } diff --git a/ytsage/utils/ytsage_library_manager.py b/ytsage/utils/ytsage_library_manager.py index 9129b77..f21e39d 100644 --- a/ytsage/utils/ytsage_library_manager.py +++ b/ytsage/utils/ytsage_library_manager.py @@ -165,14 +165,27 @@ class LibraryManager: conn.commit() @classmethod - def feed_items(cls, limit: int = 120) -> List[Dict[str, Any]]: + def feed_items(cls, limit: int = 120, channel_id: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Feed rows, newest first. `channel_id` narrows to one channel, which is + what lets the Feed merge a finished channel's videos into the grid + instead of rebuilding every card. + """ + query = ( + "SELECT f.*, s.title AS channel FROM feed_items f " + "LEFT JOIN subscriptions s ON s.channel_id = f.channel_id " + ) + params: List[Any] = [] + if channel_id is not None: + query += "WHERE f.channel_id = ? " + params.append(str(channel_id)) + # published_ts first and only then fetched_at: mixing the two in a + # single COALESCE sorted real publish times against wall-clock fetch + # times, so whichever channel refreshed last floated to the top. + query += "ORDER BY f.published_ts IS NULL, f.published_ts DESC, f.fetched_at DESC LIMIT ?" + params.append(limit) with cls._lock: - rows = cls._conn().execute( - "SELECT f.*, s.title AS channel FROM feed_items f " - "LEFT JOIN subscriptions s ON s.channel_id = f.channel_id " - "ORDER BY COALESCE(f.published_ts, f.fetched_at) DESC LIMIT ?", - (limit,), - ).fetchall() + rows = cls._conn().execute(query, tuple(params)).fetchall() return [dict(r) for r in rows] # ------------------------------------------------------- watch history