3aca43b372
There was no icon system at all. Transport buttons called QStyle.standardIcon(SP_MediaPlay), which returns the platform theme's dark monochrome glyph -- painted onto the app's saturated red buttons at a fixed 36px with no text, that is the black square. Everything else called an icon was an emoji baked into en.json, which is tofu wherever the emoji font is missing. Qt stylesheets cannot recolour a QIcon, so the colour is an argument to the new helper; that is the whole fix. The SVGs are drawn here rather than vendored, which keeps a third-party licence out of the tree, and they live in the module rather than as asset files, which keeps them out of package-data and safe in a frozen build. Tooltips were the other half: three widgets in the entire application had one and nothing set an accessible name. Filling that in at the call sites would have meant editing well over a hundred of them, most in upstream-owned files. Instead one application-level event filter handles QEvent.Polish, which Qt sends to every widget once before it is shown -- so it also reaches dialogs built by upstream code, and survives the next merge. It maps placeholder emoji to icons, fills empty tooltips from the button text, and logs icon-only buttons that still have none so the gaps are findable. StyleSheet.MAIN styles the window, inputs, buttons and tables and nothing else, so the main tab bar, combos, sliders, lists, menus, splitters, tooltips and the horizontal scrollbar fell through to the platform style. EXTRA_QSS covers them, in a fork-owned module appended at the one application site. SmoothTabWidget names its frame "tabContent" with the comment "We draw border on content instead" -- that rule existed only inside two dialogs, and now exists for the main window too. Two corrections to rules that were already there: the pressed style changed the padding, shifting every label two pixels and clipping fixed-width icon buttons, and checkboxes were fully rounded, which reads as a radio button rather than an on/off toggle. Verified by screenshot on the real display: tab bar, buttons, combo carets and checkboxes all render as intended, and all 35 icons rasterise non-empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
277 lines
11 KiB
Python
277 lines
11 KiB
Python
"""
|
|
Feed tab - subscriptions and their video feed
|
|
=============================================
|
|
|
|
Two modes (config feed.mode):
|
|
- local: aggregate the most recent uploads of every locally-subscribed
|
|
channel (no account needed). Channels refresh sequentially in one worker
|
|
thread; the grid fills incrementally as each channel lands.
|
|
- account: fetch youtube.com/feed/subscriptions with the user's cookies -
|
|
the real logged-in feed. Enabled only while cookies are active.
|
|
|
|
Subscriptions are stored in LibraryManager (sagetube_library.db); the
|
|
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.QtWidgets import (
|
|
QComboBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QMenu,
|
|
QPushButton,
|
|
QSplitter,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
|
|
from .ytsage_gui_cards import VideoCardGrid
|
|
from ..core.ytsage_client import YtdlpClient, YtdlpWorker
|
|
from ..utils.ytsage_config_manager import ConfigManager
|
|
from ..utils.ytsage_library_manager import LibraryManager
|
|
from ..utils.ytsage_localization import _
|
|
from ..utils.ytsage_logger import logger
|
|
|
|
|
|
class FeedRefreshWorker(QThread):
|
|
"""Sequentially refresh each subscription's recent uploads."""
|
|
|
|
channelDone = Signal(str) # channel_id
|
|
channelFailed = Signal(str, str) # channel_id, error
|
|
allDone = Signal()
|
|
|
|
def __init__(self, subscriptions: List[Dict[str, Any]], per_channel: int, parent=None) -> None:
|
|
super().__init__(parent)
|
|
self._subs = subscriptions
|
|
self._per_channel = per_channel
|
|
self._cancelled = False
|
|
|
|
def cancel(self) -> None:
|
|
self._cancelled = True
|
|
|
|
def run(self) -> None:
|
|
client = YtdlpClient()
|
|
for sub in self._subs:
|
|
if self._cancelled:
|
|
break
|
|
try:
|
|
entries = client.fetch_flat_entries(
|
|
f"{sub['url'].rstrip('/')}/videos", start=1, end=self._per_channel, use_cache=False
|
|
)
|
|
LibraryManager.upsert_feed_items(sub["channel_id"], entries)
|
|
LibraryManager.mark_refreshed(sub["channel_id"])
|
|
self.channelDone.emit(sub["channel_id"])
|
|
except Exception as e:
|
|
logger.warning(f"Feed refresh failed for {sub.get('title')}: {e}")
|
|
self.channelFailed.emit(sub["channel_id"], str(e))
|
|
self.allDone.emit()
|
|
|
|
|
|
class FeedPage(QWidget):
|
|
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
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(8, 8, 8, 8)
|
|
|
|
bar = QHBoxLayout()
|
|
self.mode_combo = QComboBox()
|
|
self.mode_combo.setToolTip(_("feed.mode_tooltip"))
|
|
self.mode_combo.addItem(_("feed.mode_local"), "local")
|
|
self.mode_combo.addItem(_("feed.mode_account"), "account")
|
|
self.mode_combo.setCurrentIndex(0 if (ConfigManager.get("feed.mode") or "local") == "local" else 1)
|
|
self.mode_combo.currentIndexChanged.connect(self._on_mode_changed)
|
|
bar.addWidget(self.mode_combo)
|
|
|
|
self.refresh_btn = QPushButton(_("feed.refresh"))
|
|
self.refresh_btn.setToolTip(_("feed.refresh_tooltip"))
|
|
self.refresh_btn.clicked.connect(self.refresh)
|
|
bar.addWidget(self.refresh_btn)
|
|
|
|
self.status_label = QLabel("")
|
|
self.status_label.setStyleSheet("color: #9aa0a6;")
|
|
bar.addWidget(self.status_label, stretch=1)
|
|
layout.addLayout(bar)
|
|
|
|
splitter = QSplitter(Qt.Orientation.Horizontal, self)
|
|
layout.addWidget(splitter, stretch=1)
|
|
|
|
side = QWidget()
|
|
side_layout = QVBoxLayout(side)
|
|
side_layout.setContentsMargins(0, 0, 4, 0)
|
|
subs_label = QLabel(_("feed.subscriptions"))
|
|
subs_label.setStyleSheet("font-weight: bold;")
|
|
side_layout.addWidget(subs_label)
|
|
self.subs_list = QListWidget()
|
|
self.subs_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.subs_list.customContextMenuRequested.connect(self._on_subs_context_menu)
|
|
self.subs_list.itemDoubleClicked.connect(self._on_sub_activated)
|
|
side_layout.addWidget(self.subs_list)
|
|
splitter.addWidget(side)
|
|
|
|
self.grid = VideoCardGrid(router, self)
|
|
splitter.addWidget(self.grid)
|
|
splitter.setStretchFactor(0, 1)
|
|
splitter.setStretchFactor(1, 4)
|
|
splitter.setSizes([220, 900])
|
|
|
|
self.reload_subscriptions()
|
|
self._load_cached_feed()
|
|
self._update_mode_availability()
|
|
|
|
# ------------------------------------------------------------ public API
|
|
|
|
def subscribe_channel(self, meta: Dict[str, Any]) -> None:
|
|
"""Wired to BrowsePage.subscribeRequested via the main window."""
|
|
channel_id = meta.get("channel_id") or meta.get("url")
|
|
if not channel_id or not meta.get("url"):
|
|
logger.warning(f"Cannot subscribe, missing channel info: {meta}")
|
|
return
|
|
if LibraryManager.is_subscribed(str(channel_id)):
|
|
LibraryManager.unsubscribe(str(channel_id))
|
|
self.status_label.setText(_("feed.unsubscribed", title=meta.get("title") or channel_id))
|
|
else:
|
|
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()
|
|
|
|
def reload_subscriptions(self) -> None:
|
|
self.subs_list.clear()
|
|
for sub in LibraryManager.subscriptions():
|
|
item = QListWidgetItem(sub["title"])
|
|
item.setData(Qt.ItemDataRole.UserRole, sub)
|
|
self.subs_list.addItem(item)
|
|
|
|
def refresh(self) -> None:
|
|
mode = self.mode_combo.currentData()
|
|
ConfigManager.set("feed.mode", mode)
|
|
if mode == "account":
|
|
self._refresh_account()
|
|
else:
|
|
self._refresh_local()
|
|
|
|
# --------------------------------------------------------------- local
|
|
|
|
def _refresh_local(self) -> None:
|
|
if self._refresh_worker is not None:
|
|
return
|
|
subs = LibraryManager.subscriptions()
|
|
if not subs:
|
|
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._total_count = len(subs)
|
|
self._refresh_worker = FeedRefreshWorker(subs, per_channel, parent=self)
|
|
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)
|
|
self._refresh_worker.start()
|
|
|
|
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()
|
|
|
|
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]))
|
|
|
|
def _on_refresh_done(self) -> None:
|
|
self.refresh_btn.setEnabled(True)
|
|
self.status_label.setText(_("feed.refreshed", count=self.grid.card_count()))
|
|
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 = [
|
|
{
|
|
"id": it["video_id"],
|
|
"url": it["url"],
|
|
"title": it["title"],
|
|
"channel": it.get("channel"),
|
|
"duration": it["duration"],
|
|
"thumbnail": it["thumbnail_url"],
|
|
}
|
|
for it in items
|
|
]
|
|
self.grid.set_entries(entries)
|
|
|
|
# -------------------------------------------------------------- account
|
|
|
|
def _refresh_account(self) -> None:
|
|
if self._account_worker is not None:
|
|
return
|
|
self.refresh_btn.setEnabled(False)
|
|
self.status_label.setText(_("feed.fetching_account"))
|
|
self._account_worker = YtdlpWorker(lambda c: c.fetch_account_feed(n=60), parent=self)
|
|
self._account_worker.result.connect(self._on_account_feed)
|
|
self._account_worker.error.connect(self._on_account_error)
|
|
self._account_worker.finished.connect(self._on_account_finished)
|
|
self._account_worker.start()
|
|
|
|
def _on_account_feed(self, entries: List[Dict[str, Any]]) -> None:
|
|
self.grid.set_entries(entries)
|
|
self.status_label.setText(_("feed.refreshed", count=len(entries)))
|
|
|
|
def _on_account_error(self, message: str) -> None:
|
|
logger.error(f"Account feed failed: {message}")
|
|
self.status_label.setText(_("feed.account_failed", error=message[:200]))
|
|
|
|
def _on_account_finished(self) -> None:
|
|
self.refresh_btn.setEnabled(True)
|
|
if self._account_worker is not None:
|
|
self._account_worker.deleteLater()
|
|
self._account_worker = None
|
|
|
|
# ------------------------------------------------------------- internal
|
|
|
|
def _update_mode_availability(self) -> None:
|
|
cookies_on = bool(ConfigManager.get("cookie_active"))
|
|
account_index = self.mode_combo.findData("account")
|
|
model_item = self.mode_combo.model().item(account_index)
|
|
if model_item is not None:
|
|
model_item.setEnabled(cookies_on)
|
|
if not cookies_on and self.mode_combo.currentData() == "account":
|
|
self.mode_combo.setCurrentIndex(self.mode_combo.findData("local"))
|
|
|
|
def showEvent(self, event) -> None:
|
|
self._update_mode_availability()
|
|
super().showEvent(event)
|
|
|
|
def _on_mode_changed(self) -> None:
|
|
ConfigManager.set("feed.mode", self.mode_combo.currentData())
|
|
|
|
def _on_sub_activated(self, item: QListWidgetItem) -> None:
|
|
sub = item.data(Qt.ItemDataRole.UserRole)
|
|
if sub and sub.get("url"):
|
|
self._router.openChannel.emit(sub["url"])
|
|
|
|
def _on_subs_context_menu(self, pos) -> None:
|
|
item = self.subs_list.itemAt(pos)
|
|
if item is None:
|
|
return
|
|
sub = item.data(Qt.ItemDataRole.UserRole)
|
|
menu = QMenu(self)
|
|
open_action = menu.addAction(_("feed.open_channel"))
|
|
unsub_action = menu.addAction(_("browse.unsubscribe"))
|
|
action = menu.exec(self.subs_list.mapToGlobal(pos))
|
|
if action is open_action and sub.get("url"):
|
|
self._router.openChannel.emit(sub["url"])
|
|
elif action is unsub_action:
|
|
LibraryManager.unsubscribe(sub["channel_id"])
|
|
self.reload_subscriptions()
|
|
self._load_cached_feed()
|